doc2vec 2.5.0 → 2.7.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 CHANGED
@@ -36,6 +36,10 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
36
36
  * **Vector Storage:** Supports storing chunks, metadata, and embeddings in:
37
37
  * **SQLite:** Using `better-sqlite3` and the `sqlite-vec` extension for efficient vector search.
38
38
  * **Qdrant:** A dedicated vector database, using the `@qdrant/js-client-rest`.
39
+ * **[Postgres Markdown Store](MARKDOWN_STORE.md):** Optionally stores the generated markdown for each crawled URL in a Postgres table. Useful for maintaining a searchable, raw-text copy of all documentation pages alongside the vector embeddings.
40
+ * **Automatic population:** On the first sync, all pages are force-processed (bypassing lastmod/ETag caching) to fully populate the store. Subsequent syncs only update rows when a change is detected.
41
+ * **404 cleanup:** Pages that return 404 are automatically removed from the store.
42
+ * **Shared table:** A single table (configurable name, default `markdown_pages`) is shared across all sources, with a `product_name` column to distinguish them.
39
43
  * **Multi-Layer Change Detection:** Four layers of change detection minimize unnecessary re-processing:
40
44
  1. **Sitemap `lastmod`:** When available, compares the sitemap's `<lastmod>` date against the stored value — skips without any HTTP request. Child URLs inherit `lastmod` from their most specific parent directory.
41
45
  2. **ETag via HEAD request:** For URLs without `lastmod`, sends a lightweight HEAD request and compares the ETag header against the stored value. Adaptive backoff prevents rate limiting (starts at 0ms delay, increases on 429 responses, decays on success).
@@ -43,6 +47,8 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
43
47
  4. **Embedding:** Only re-embeds chunks when content has actually changed.
44
48
 
45
49
  ETag and lastmod values are only stored when chunking and embedding succeed, ensuring failed pages are retried on the next run.
50
+
51
+ A `sync_complete` metadata flag tracks whether a full sync has ever completed successfully. If a sync is interrupted (process killed), the next run force-processes all pages regardless of lastmod/ETag values, ensuring no pages are permanently skipped.
46
52
  * **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
47
53
  * **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
48
54
  * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
@@ -170,11 +176,12 @@ Configuration is managed through two files:
170
176
  **Structure:**
171
177
 
172
178
  * `sources`: An array of source configurations.
173
- * `type`: Either `'website'`, `'github'`, `'local_directory'`, `'code'`, or `'zendesk'`
179
+ * `type`: Either `'website'`, `'github'`, `'local_directory'`, `'code'`, `'zendesk'`, or `'s3'`
174
180
 
175
181
  For websites (`type: 'website'`):
176
182
  * `url`: The starting URL for crawling the documentation site.
177
183
  * `sitemap_url`: (Optional) URL to the site's XML sitemap for discovering additional pages not linked in navigation.
184
+ * `markdown_store`: (Optional) Set to `true` to store generated markdown in the Postgres markdown store (requires top-level `markdown_store` config). Defaults to `false`.
178
185
 
179
186
  For GitHub repositories (`type: 'github'`):
180
187
  * `repo`: Repository name in the format `'owner/repo'` (e.g., `'istio/istio'`).
@@ -212,6 +219,20 @@ Configuration is managed through two files:
212
219
  * `ticket_status`: (Optional) Filter tickets by status (defaults to `['new', 'open', 'pending', 'hold', 'solved']`).
213
220
  * `ticket_priority`: (Optional) Filter tickets by priority (defaults to all priorities).
214
221
 
222
+ For S3 buckets (`type: 's3'`):
223
+ * `bucket`: The S3 bucket name.
224
+ * `prefix`: (Optional) Key prefix to filter objects (e.g., `'docs/'`). Only objects under this prefix will be processed.
225
+ * `region`: (Optional) AWS region (defaults to `AWS_DEFAULT_REGION` environment variable or `'us-east-1'`).
226
+ * `endpoint`: (Optional) Custom S3 endpoint for S3-compatible services (MinIO, LocalStack, etc.).
227
+ * `include_extensions`: (Optional) Array of file extensions to include (e.g., `['.md', '.txt', '.pdf']`). Defaults to `['.md', '.txt', '.html', '.htm', '.pdf', '.doc', '.docx']`.
228
+ * `exclude_extensions`: (Optional) Array of file extensions to exclude.
229
+ * `encoding`: (Optional) Text file encoding (defaults to `'utf8'`). Does not apply to binary files (PDF, DOC, DOCX).
230
+ * `url_rewrite_prefix`: (Optional) URL prefix to rewrite `s3://` URLs (e.g., `'https://docs.example.com'`).
231
+
232
+ Authentication uses the AWS SDK default credential chain: environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), `~/.aws/credentials`, IAM roles, etc.
233
+
234
+ Incremental sync tracks object `LastModified` timestamps so only new or updated objects are processed on subsequent runs. Deleted objects are automatically cleaned up.
235
+
215
236
  Common configuration for all types:
216
237
  * `product_name`: A string identifying the product (used in metadata).
217
238
  * `version`: A string identifying the product version (used in metadata).
@@ -230,6 +251,17 @@ Configuration is managed through two files:
230
251
  * `embedding.provider`: Provider for embeddings (`openai` or `azure`).
231
252
  * `embedding.dimension`: Embedding vector size. Defaults to `3072` when not set.
232
253
 
254
+ Optional Postgres markdown store (top-level):
255
+ * `markdown_store.connection_string`: (Optional) Full Postgres connection string (e.g., `'postgres://user:pass@host:5432/db'`). Takes priority over individual fields.
256
+ * `markdown_store.host`: (Optional) Postgres host.
257
+ * `markdown_store.port`: (Optional) Postgres port.
258
+ * `markdown_store.database`: (Optional) Postgres database name.
259
+ * `markdown_store.user`: (Optional) Postgres user.
260
+ * `markdown_store.password`: (Optional) Postgres password. Supports `${PG_PASSWORD}` env var substitution.
261
+ * `markdown_store.table_name`: (Optional) Table name. Defaults to `'markdown_pages'`.
262
+
263
+ When configured, website sources with `markdown_store: true` will store the generated markdown for each URL in this Postgres table. On the first sync, all pages are force-processed (bypassing lastmod/ETag skip logic) to populate the table. On subsequent syncs, only pages with detected changes get their rows updated.
264
+
233
265
  **Example (`config.yaml`):**
234
266
  ```yaml
235
267
  # Optional: Configure embedding provider
@@ -248,13 +280,25 @@ Configuration is managed through two files:
248
280
  # deployment_name: 'text-embedding-3-large'
249
281
  # api_version: '2024-10-21' # Optional
250
282
 
283
+ # Optional: Store generated markdown in Postgres
284
+ # markdown_store:
285
+ # connection_string: 'postgres://user:pass@host:5432/db'
286
+ # # OR use individual fields:
287
+ # # host: 'localhost'
288
+ # # port: 5432
289
+ # # database: 'doc2vec'
290
+ # # user: 'myuser'
291
+ # # password: '${PG_PASSWORD}'
292
+ # # table_name: 'markdown_pages' # Optional, defaults to 'markdown_pages'
293
+
251
294
  sources:
252
- # Website source example
295
+ # Website source example (with markdown store enabled)
253
296
  - type: 'website'
254
297
  product_name: 'argo'
255
298
  version: 'stable'
256
299
  url: 'https://argo-cd.readthedocs.io/en/stable/'
257
300
  sitemap_url: 'https://argo-cd.readthedocs.io/en/stable/sitemap.xml'
301
+ markdown_store: true # Store generated markdown in Postgres
258
302
  max_size: 1048576
259
303
  database_config:
260
304
  type: 'sqlite'
@@ -319,6 +363,21 @@ Configuration is managed through two files:
319
363
  params:
320
364
  db_path: './zendesk-kb.db'
321
365
 
366
+ # S3 bucket source example
367
+ - type: 's3'
368
+ product_name: 'my-docs'
369
+ version: 'latest'
370
+ bucket: 'my-documentation-bucket'
371
+ prefix: 'docs/v2/'
372
+ region: 'us-west-2'
373
+ include_extensions: ['.md', '.txt', '.pdf', '.html']
374
+ url_rewrite_prefix: 'https://docs.example.com'
375
+ max_size: 1048576
376
+ database_config:
377
+ type: 'sqlite'
378
+ params:
379
+ db_path: './s3-docs.db'
380
+
322
381
  # Qdrant example
323
382
  - type: 'website'
324
383
  product_name: 'Istio'
@@ -548,6 +607,20 @@ If you don't specify a config path, it will look for config.yaml in the current
548
607
 
549
608
  ## Recent Changes
550
609
 
610
+ ### Postgres Markdown Store
611
+ - **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
612
+ - **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
613
+ - **Per-source opt-in:** Enable per website source with `markdown_store: true` (disabled by default)
614
+ - **First-sync force-processing:** When the markdown store is enabled, pages that aren't yet in the Postgres table bypass lastmod and ETag skip logic, ensuring all pages are stored on the first sync
615
+ - **Change-only updates:** On subsequent syncs, only pages with detected content changes (via lastmod/ETag) have their Postgres rows updated
616
+ - **404 cleanup:** Pages that return 404 during HEAD checks are automatically removed from the Postgres store
617
+
618
+ ### Incomplete Sync Recovery
619
+ - **New feature:** Tracks whether a full sync has ever completed successfully for each website source via a `sync_complete:<url_prefix>` metadata key
620
+ - **Interrupted sync handling:** If a sync is killed mid-crawl (process terminated, crash, etc.), the stored ETags/lastmods from the partial run would otherwise cause remaining pages to be skipped permanently. The `sync_complete` flag prevents this — when absent, all pages are force-processed regardless of caching signals
621
+ - **Gated on clean completion:** The flag is only set when the crawl completes without network errors (DNS failures, connection refused, timeouts). If the site is unreachable, the next run will force a full sync again
622
+ - **Scoped per source:** Each website source has its own `sync_complete` key based on its URL prefix. Changing the source URL naturally triggers a new full sync
623
+
551
624
  ### Multi-Layer Change Detection for Websites
552
625
  - **Sitemap `lastmod` support:** When a sitemap includes `<lastmod>` dates, pages are skipped entirely if the date hasn't changed — no HEAD request, no Puppeteer load, no chunking. One sitemap fetch replaces hundreds of individual HEAD requests.
553
626
  - **`lastmod` inheritance:** Child URLs without their own `<lastmod>` inherit from the most specific parent directory URL in the sitemap (e.g., `/docs/2.10.x/reference/cli/` inherits from `/docs/2.10.x/`).
@@ -218,6 +218,7 @@ class ContentProcessor {
218
218
  const queue = [baseUrl];
219
219
  const brokenLinks = [];
220
220
  const brokenLinkKeys = new Set();
221
+ const notFoundUrls = new Set();
221
222
  const referrers = new Map();
222
223
  const addReferrer = (targetUrl, sourceUrl) => {
223
224
  const existing = referrers.get(targetUrl);
@@ -236,7 +237,7 @@ class ContentProcessor {
236
237
  brokenLinks.push({ source: sourceUrl, target: targetUrl });
237
238
  };
238
239
  addReferrer(baseUrl, baseUrl);
239
- const { knownUrls, etagStore, lastmodStore } = options ?? {};
240
+ const { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync } = options ?? {};
240
241
  // Pre-seed queue with previously-known URLs so link discovery isn't lost
241
242
  // when pages are skipped via ETag/lastmod matching on re-runs
242
243
  if (knownUrls && knownUrls.size > 0) {
@@ -422,14 +423,26 @@ class ContentProcessor {
422
423
  // this URL and it matches the stored value, skip entirely. This avoids
423
424
  // both HEAD requests and full page loads — the sitemap is fetched once
424
425
  // for the entire crawl.
426
+ // Bypass this skip when:
427
+ // - forceFullSync is true (previous sync never completed)
428
+ // - markdownStoreUrls is set and the URL is not in it (markdown store gap)
425
429
  const urlLastmod = sitemapLastmod.get(url);
430
+ const urlInMarkdownStore = !markdownStoreUrls || markdownStoreUrls.has(url);
426
431
  if (urlLastmod && lastmodStore) {
427
432
  const storedLastmod = await lastmodStore.get(url);
428
433
  if (storedLastmod && storedLastmod === urlLastmod) {
429
- logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
430
- visitedUrls.add(url);
431
- lastmodSkippedCount++;
432
- continue;
434
+ if (forceFullSync) {
435
+ logger.info(`Lastmod unchanged but full sync not yet completed, forcing processing: ${url}`);
436
+ }
437
+ else if (!urlInMarkdownStore) {
438
+ logger.info(`Lastmod unchanged but URL not in markdown store, forcing processing: ${url}`);
439
+ }
440
+ else {
441
+ logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
442
+ visitedUrls.add(url);
443
+ lastmodSkippedCount++;
444
+ continue;
445
+ }
433
446
  }
434
447
  else if (storedLastmod) {
435
448
  logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
@@ -494,10 +507,21 @@ class ContentProcessor {
494
507
  decayBackoff();
495
508
  const result = await checkEtag(headResponse.headers['etag']);
496
509
  if (result === 'skip') {
497
- logger.info(`ETag unchanged, skipping: ${url}`);
498
- visitedUrls.add(url);
499
- etagSkippedCount++;
500
- continue;
510
+ // Bypass ETag skip when:
511
+ // - forceFullSync is true (previous sync never completed)
512
+ // - markdownStoreUrls is set and URL is not in it
513
+ if (forceFullSync) {
514
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
515
+ }
516
+ else if (!urlInMarkdownStore) {
517
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
518
+ }
519
+ else {
520
+ logger.info(`ETag unchanged, skipping: ${url}`);
521
+ visitedUrls.add(url);
522
+ etagSkippedCount++;
523
+ continue;
524
+ }
501
525
  }
502
526
  else if (result === 'no-etag') {
503
527
  consecutiveMissingEtag++;
@@ -524,10 +548,18 @@ class ContentProcessor {
524
548
  decayBackoff();
525
549
  const result = await checkEtag(retryResponse.headers['etag']);
526
550
  if (result === 'skip') {
527
- logger.info(`ETag unchanged, skipping: ${url}`);
528
- visitedUrls.add(url);
529
- etagSkippedCount++;
530
- continue;
551
+ if (forceFullSync) {
552
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
553
+ }
554
+ else if (!urlInMarkdownStore) {
555
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
556
+ }
557
+ else {
558
+ logger.info(`ETag unchanged, skipping: ${url}`);
559
+ visitedUrls.add(url);
560
+ etagSkippedCount++;
561
+ continue;
562
+ }
531
563
  }
532
564
  // 'process' or 'no-etag' — fall through to full processing
533
565
  }
@@ -542,6 +574,9 @@ class ContentProcessor {
542
574
  // 405 (Method Not Allowed) is excluded because the server
543
575
  // may not support HEAD but the page may still exist via GET.
544
576
  logger.debug(`HEAD returned ${headStatus} for ${url}, skipping`);
577
+ if (headStatus === 404) {
578
+ notFoundUrls.add(url);
579
+ }
545
580
  skippedCount++;
546
581
  continue;
547
582
  }
@@ -707,7 +742,7 @@ class ContentProcessor {
707
742
  if (hasNetworkErrors) {
708
743
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
709
744
  }
710
- return { hasNetworkErrors, brokenLinks };
745
+ return { hasNetworkErrors, brokenLinks, notFoundUrls };
711
746
  }
712
747
  /**
713
748
  * Detects Chrome DevTools Protocol-level errors that indicate the browser
@@ -1271,6 +1306,39 @@ class ContentProcessor {
1271
1306
  throw error;
1272
1307
  }
1273
1308
  }
1309
+ async convertFileToMarkdown(filePath, extension, logger) {
1310
+ const ext = extension.toLowerCase();
1311
+ if (ext === '.pdf') {
1312
+ return this.convertPdfToMarkdown(filePath, logger);
1313
+ }
1314
+ else if (ext === '.doc') {
1315
+ return this.convertDocToMarkdown(filePath, logger);
1316
+ }
1317
+ else if (ext === '.docx') {
1318
+ return this.convertDocxToMarkdown(filePath, logger);
1319
+ }
1320
+ else if (ext === '.html' || ext === '.htm') {
1321
+ const content = fs.readFileSync(filePath, { encoding: 'utf8' });
1322
+ const cleanHtml = (0, sanitize_html_1.default)(content, {
1323
+ allowedTags: [
1324
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1325
+ 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1326
+ 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1327
+ ],
1328
+ allowedAttributes: {
1329
+ 'a': ['href'],
1330
+ 'pre': ['class', 'data-language'],
1331
+ 'code': ['class', 'data-language'],
1332
+ 'div': ['class'],
1333
+ 'span': ['class']
1334
+ }
1335
+ });
1336
+ return this.turndownService.turndown(cleanHtml);
1337
+ }
1338
+ else {
1339
+ throw new Error(`Unsupported file extension for conversion: ${extension}`);
1340
+ }
1341
+ }
1274
1342
  async downloadAndConvertPdfFromUrl(url, logger) {
1275
1343
  logger.debug(`Downloading and converting PDF from URL: ${url}`);
1276
1344
  try {
@@ -1393,20 +1461,18 @@ class ContentProcessor {
1393
1461
  logger.info(`Reading file: ${filePath}`);
1394
1462
  let content;
1395
1463
  let processedContent;
1396
- if (extension === '.pdf') {
1397
- // Handle PDF files
1398
- logger.debug(`Processing PDF file: ${filePath}`);
1399
- processedContent = await this.convertPdfToMarkdown(filePath, logger);
1400
- }
1401
- else if (extension === '.doc') {
1402
- // Handle legacy Word DOC files
1403
- logger.debug(`Processing DOC file: ${filePath}`);
1404
- processedContent = await this.convertDocToMarkdown(filePath, logger);
1405
- }
1406
- else if (extension === '.docx') {
1407
- // Handle modern Word DOCX files
1408
- logger.debug(`Processing DOCX file: ${filePath}`);
1409
- processedContent = await this.convertDocxToMarkdown(filePath, logger);
1464
+ const convertibleExtensions = ['.pdf', '.doc', '.docx', '.html', '.htm'];
1465
+ if (convertibleExtensions.includes(extension)) {
1466
+ if (extension === '.html' || extension === '.htm') {
1467
+ // For HTML, check raw file size before converting
1468
+ content = fs.readFileSync(filePath, { encoding: encoding });
1469
+ if (content.length > config.max_size) {
1470
+ logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
1471
+ skippedFiles++;
1472
+ continue;
1473
+ }
1474
+ }
1475
+ processedContent = await this.convertFileToMarkdown(filePath, extension, logger);
1410
1476
  }
1411
1477
  else {
1412
1478
  // Handle text-based files
@@ -1416,28 +1482,7 @@ class ContentProcessor {
1416
1482
  skippedFiles++;
1417
1483
  continue;
1418
1484
  }
1419
- // Convert HTML to Markdown if needed
1420
- if (extension === '.html' || extension === '.htm') {
1421
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
1422
- const cleanHtml = (0, sanitize_html_1.default)(content, {
1423
- allowedTags: [
1424
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1425
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1426
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1427
- ],
1428
- allowedAttributes: {
1429
- 'a': ['href'],
1430
- 'pre': ['class', 'data-language'],
1431
- 'code': ['class', 'data-language'],
1432
- 'div': ['class'],
1433
- 'span': ['class']
1434
- }
1435
- });
1436
- processedContent = this.turndownService.turndown(cleanHtml);
1437
- }
1438
- else {
1439
- processedContent = content;
1440
- }
1485
+ processedContent = content;
1441
1486
  }
1442
1487
  // Check size limit for processed content
1443
1488
  if (processedContent.length > config.max_size) {
package/dist/database.js CHANGED
@@ -257,8 +257,8 @@ class DatabaseManager {
257
257
  logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
258
258
  return defaultDate;
259
259
  }
260
- static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension) {
261
- const now = new Date().toISOString();
260
+ static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension, date) {
261
+ const now = date;
262
262
  try {
263
263
  if (dbConnection.type === 'sqlite') {
264
264
  const metadataKey = `last_run_${repo.replace('/', '_')}`;
package/dist/doc2vec.js CHANGED
@@ -53,6 +53,8 @@ const logger_1 = require("./logger");
53
53
  const utils_1 = require("./utils");
54
54
  const database_1 = require("./database");
55
55
  const content_processor_1 = require("./content-processor");
56
+ const client_s3_1 = require("@aws-sdk/client-s3");
57
+ const markdown_store_1 = require("./markdown-store");
56
58
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
57
59
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
58
60
  dotenv.config();
@@ -103,6 +105,10 @@ class Doc2Vec {
103
105
  this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
104
106
  }
105
107
  this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
108
+ // Initialize Postgres markdown store if configured
109
+ if (this.config.markdown_store) {
110
+ this.markdownStore = new markdown_store_1.MarkdownStore(this.config.markdown_store, this.logger);
111
+ }
106
112
  }
107
113
  loadConfig(configPath) {
108
114
  try {
@@ -161,6 +167,10 @@ class Doc2Vec {
161
167
  return parsedValue;
162
168
  }
163
169
  async run() {
170
+ // Initialize Postgres markdown store table if configured
171
+ if (this.markdownStore) {
172
+ await this.markdownStore.init();
173
+ }
164
174
  this.logger.section('PROCESSING SOURCES');
165
175
  for (const sourceConfig of this.config.sources) {
166
176
  const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
@@ -180,10 +190,17 @@ class Doc2Vec {
180
190
  else if (sourceConfig.type === 'zendesk') {
181
191
  await this.processZendesk(sourceConfig, sourceLogger);
182
192
  }
193
+ else if (sourceConfig.type === 's3') {
194
+ await this.processS3(sourceConfig, sourceLogger);
195
+ }
183
196
  else {
184
197
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
185
198
  }
186
199
  }
200
+ // Close the Postgres markdown store connection pool
201
+ if (this.markdownStore) {
202
+ await this.markdownStore.close();
203
+ }
187
204
  this.logger.section('PROCESSING COMPLETE');
188
205
  }
189
206
  async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
@@ -191,6 +208,8 @@ class Doc2Vec {
191
208
  const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
192
209
  // Initialize metadata storage if needed
193
210
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
211
+ // Capture timestamp at the start so issues updated during sync are re-processed next run
212
+ const syncStartDate = new Date().toISOString();
194
213
  // Get the last run date from the database
195
214
  const startDate = sourceConfig.start_date || '2025-01-01';
196
215
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
@@ -389,7 +408,7 @@ class Doc2Vec {
389
408
  await processIssue(issues[i]);
390
409
  }
391
410
  // Update the last run date in the database after processing all issues
392
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension);
411
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
393
412
  logger.info(`Successfully processed ${issues.length} issues`);
394
413
  }
395
414
  async processGithubRepo(config, parentLogger) {
@@ -447,6 +466,28 @@ class Doc2Vec {
447
466
  await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
448
467
  },
449
468
  };
469
+ // If Postgres markdown store is enabled for this source, load URLs that
470
+ // already have markdown stored. URLs NOT in this set will bypass
471
+ // lastmod/ETag skip logic so that the store is fully populated on the
472
+ // first sync.
473
+ const useMarkdownStore = config.markdown_store === true && this.markdownStore != null;
474
+ let markdownStoreUrls;
475
+ if (useMarkdownStore) {
476
+ markdownStoreUrls = await this.markdownStore.getUrlsWithMarkdown(urlPrefix);
477
+ logger.info(`Markdown store: ${markdownStoreUrls.size} URLs already stored for prefix ${urlPrefix}`);
478
+ }
479
+ // Check whether a full sync has ever completed successfully for this
480
+ // source. If not, bypass all lastmod/ETag skip logic so that every
481
+ // page is processed at least once. This handles the case where a
482
+ // previous sync was interrupted (killed mid-crawl) — the stored
483
+ // ETags/lastmods from the partial run would otherwise cause the
484
+ // remaining pages to be skipped indefinitely.
485
+ const syncCompleteKey = `sync_complete:${urlPrefix}`;
486
+ const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
487
+ const forceFullSync = syncCompleteValue !== 'true';
488
+ if (forceFullSync) {
489
+ logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
490
+ }
450
491
  const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
451
492
  visitedUrls.add(url);
452
493
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
@@ -457,21 +498,50 @@ class Doc2Vec {
457
498
  validChunkIds.add(chunk.metadata.chunk_id);
458
499
  }
459
500
  await this.processChunksForUrl(chunks, url, dbConnection, logger);
501
+ // Store the generated markdown in Postgres
502
+ if (useMarkdownStore) {
503
+ try {
504
+ await this.markdownStore.upsertMarkdown(url, config.product_name, content);
505
+ }
506
+ catch (pgError) {
507
+ logger.error(`Failed to store markdown in Postgres for ${url}:`, pgError);
508
+ }
509
+ }
460
510
  return true;
461
511
  }
462
512
  catch (error) {
463
513
  logger.error(`Error during chunking or embedding for ${url}:`, error);
464
514
  return false;
465
515
  }
466
- }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
516
+ }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync });
467
517
  this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
468
518
  this.writeBrokenLinksReport();
469
519
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
470
520
  logger.section('CLEANUP');
521
+ // Remove 404 URLs from the Postgres markdown store
522
+ if (useMarkdownStore && crawlResult.notFoundUrls && crawlResult.notFoundUrls.size > 0) {
523
+ logger.info(`Removing ${crawlResult.notFoundUrls.size} not-found URLs from markdown store`);
524
+ for (const url of crawlResult.notFoundUrls) {
525
+ try {
526
+ await this.markdownStore.deleteMarkdown(url);
527
+ }
528
+ catch (pgError) {
529
+ logger.error(`Failed to delete markdown from Postgres for ${url}:`, pgError);
530
+ }
531
+ }
532
+ }
471
533
  if (crawlResult.hasNetworkErrors) {
472
534
  logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
473
535
  }
474
536
  else {
537
+ // Mark this source as having completed a full sync. On subsequent
538
+ // runs, lastmod/ETag skip logic will function normally. If the
539
+ // process is killed before reaching this point, the flag stays
540
+ // unset and the next run will force-process all pages again.
541
+ if (forceFullSync) {
542
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
543
+ logger.info('Full sync completed successfully — subsequent runs will use normal caching');
544
+ }
475
545
  if (dbConnection.type === 'sqlite') {
476
546
  logger.info(`Running SQLite cleanup for ${urlPrefix}`);
477
547
  database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
@@ -573,6 +643,191 @@ class Doc2Vec {
573
643
  }
574
644
  logger.info(`Finished processing local directory: ${config.path}`);
575
645
  }
646
+ async processS3(config, parentLogger) {
647
+ const logger = parentLogger.child('process');
648
+ logger.info(`Starting processing for S3 bucket: ${config.bucket}${config.prefix ? ` (prefix: ${config.prefix})` : ''}`);
649
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
650
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
651
+ const processedFiles = new Set();
652
+ // Capture timestamp at the start so objects modified during sync are re-processed next run
653
+ const syncStartTimestamp = Date.now();
654
+ const s3Client = new client_s3_1.S3Client({
655
+ region: config.region || process.env.AWS_DEFAULT_REGION || 'us-east-1',
656
+ ...(config.endpoint ? { endpoint: config.endpoint, forcePathStyle: true } : {})
657
+ });
658
+ // Metadata keys for incremental sync
659
+ const sanitizedPrefix = (config.prefix || '').replace(/[^a-zA-Z0-9]+/g, '_');
660
+ const bucketKey = `${config.bucket}_${sanitizedPrefix}`;
661
+ const lastSyncKey = `s3_last_sync_${bucketKey}`;
662
+ const fileListKey = `s3_filelist_${bucketKey}`;
663
+ const lastSyncValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, lastSyncKey, '0', logger);
664
+ const lastSyncTimestamp = parseInt(lastSyncValue || '0', 10);
665
+ const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm', '.pdf', '.doc', '.docx'];
666
+ const excludeExtensions = config.exclude_extensions || [];
667
+ const encoding = config.encoding || 'utf8';
668
+ // List all matching objects
669
+ logger.section('S3 OBJECT LISTING');
670
+ const allObjects = [];
671
+ let continuationToken;
672
+ do {
673
+ const response = await s3Client.send(new client_s3_1.ListObjectsV2Command({
674
+ Bucket: config.bucket,
675
+ Prefix: config.prefix || undefined,
676
+ ContinuationToken: continuationToken,
677
+ }));
678
+ for (const obj of response.Contents || []) {
679
+ if (!obj.Key || obj.Key.endsWith('/'))
680
+ continue; // skip folder markers
681
+ const extension = path.extname(obj.Key).toLowerCase();
682
+ if (excludeExtensions.includes(extension))
683
+ continue;
684
+ if (includeExtensions.length > 0 && !includeExtensions.includes(extension))
685
+ continue;
686
+ allObjects.push({
687
+ key: obj.Key,
688
+ lastModified: obj.LastModified,
689
+ size: obj.Size || 0,
690
+ });
691
+ }
692
+ continuationToken = response.NextContinuationToken;
693
+ } while (continuationToken);
694
+ logger.info(`Found ${allObjects.length} matching objects in S3 bucket`);
695
+ // Process objects
696
+ logger.section('S3 FILE PROCESSING AND EMBEDDING');
697
+ let processedCount = 0;
698
+ let skippedCount = 0;
699
+ for (const obj of allObjects) {
700
+ processedFiles.add(obj.key);
701
+ // Incremental check: skip if object hasn't been modified since last sync
702
+ if (lastSyncTimestamp > 0 && obj.lastModified.getTime() <= lastSyncTimestamp) {
703
+ logger.debug(`Skipping unchanged object: ${obj.key}`);
704
+ skippedCount++;
705
+ continue;
706
+ }
707
+ // Size check
708
+ if (obj.size > config.max_size) {
709
+ logger.warn(`Object ${obj.key} (${obj.size} bytes) exceeds max_size (${config.max_size}). Skipping.`);
710
+ skippedCount++;
711
+ continue;
712
+ }
713
+ logger.info(`Processing S3 object: ${obj.key} (${obj.size} bytes)`);
714
+ try {
715
+ const getResponse = await s3Client.send(new client_s3_1.GetObjectCommand({
716
+ Bucket: config.bucket,
717
+ Key: obj.key,
718
+ }));
719
+ const extension = path.extname(obj.key).toLowerCase();
720
+ let content;
721
+ const binaryExtensions = ['.pdf', '.doc', '.docx'];
722
+ if (binaryExtensions.includes(extension)) {
723
+ // Binary files: write to temp file and convert
724
+ const bodyBytes = await getResponse.Body.transformToByteArray();
725
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 's3-doc2vec-'));
726
+ const tempFilePath = path.join(tempDir, path.basename(obj.key));
727
+ try {
728
+ fs.writeFileSync(tempFilePath, buffer_1.Buffer.from(bodyBytes));
729
+ content = await this.contentProcessor.convertFileToMarkdown(tempFilePath, extension, logger);
730
+ }
731
+ finally {
732
+ // Cleanup temp files
733
+ try {
734
+ fs.unlinkSync(tempFilePath);
735
+ }
736
+ catch { }
737
+ try {
738
+ fs.rmdirSync(tempDir);
739
+ }
740
+ catch { }
741
+ }
742
+ }
743
+ else if (extension === '.html' || extension === '.htm') {
744
+ // HTML files: write to temp, convert via contentProcessor
745
+ const bodyString = await getResponse.Body.transformToString(encoding);
746
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 's3-doc2vec-'));
747
+ const tempFilePath = path.join(tempDir, path.basename(obj.key));
748
+ try {
749
+ fs.writeFileSync(tempFilePath, bodyString, { encoding });
750
+ content = await this.contentProcessor.convertFileToMarkdown(tempFilePath, extension, logger);
751
+ }
752
+ finally {
753
+ try {
754
+ fs.unlinkSync(tempFilePath);
755
+ }
756
+ catch { }
757
+ try {
758
+ fs.rmdirSync(tempDir);
759
+ }
760
+ catch { }
761
+ }
762
+ }
763
+ else {
764
+ // Text files: read directly as string
765
+ content = await getResponse.Body.transformToString(encoding);
766
+ }
767
+ if (content.length > config.max_size) {
768
+ logger.warn(`Processed content for ${obj.key} (${content.length} chars) exceeds max_size. Skipping.`);
769
+ skippedCount++;
770
+ continue;
771
+ }
772
+ // Generate URL
773
+ let fileUrl;
774
+ if (config.url_rewrite_prefix) {
775
+ const prefix = config.url_rewrite_prefix.endsWith('/')
776
+ ? config.url_rewrite_prefix.slice(0, -1)
777
+ : config.url_rewrite_prefix;
778
+ const relativePath = config.prefix
779
+ ? obj.key.substring(config.prefix.length).replace(/^\//, '')
780
+ : obj.key;
781
+ fileUrl = `${prefix}/${relativePath}`;
782
+ }
783
+ else {
784
+ fileUrl = `s3://${config.bucket}/${obj.key}`;
785
+ }
786
+ const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
787
+ logger.info(`Created ${chunks.length} chunks for ${obj.key}`);
788
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
789
+ processedCount++;
790
+ }
791
+ catch (error) {
792
+ logger.error(`Error processing S3 object ${obj.key}:`, error);
793
+ }
794
+ }
795
+ logger.info(`Processed: ${processedCount}, Skipped: ${skippedCount}`);
796
+ // Cleanup: remove chunks for deleted objects
797
+ logger.section('CLEANUP');
798
+ const previousListValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, fileListKey, '[]', logger);
799
+ const previousList = previousListValue ? JSON.parse(previousListValue) : [];
800
+ const deletedKeys = previousList.filter(key => !processedFiles.has(key));
801
+ if (deletedKeys.length > 0) {
802
+ logger.info(`Removing chunks for ${deletedKeys.length} deleted objects`);
803
+ for (const deletedKey of deletedKeys) {
804
+ let fileUrl;
805
+ if (config.url_rewrite_prefix) {
806
+ const prefix = config.url_rewrite_prefix.endsWith('/')
807
+ ? config.url_rewrite_prefix.slice(0, -1)
808
+ : config.url_rewrite_prefix;
809
+ const relativePath = config.prefix
810
+ ? deletedKey.substring(config.prefix.length).replace(/^\//, '')
811
+ : deletedKey;
812
+ fileUrl = `${prefix}/${relativePath}`;
813
+ }
814
+ else {
815
+ fileUrl = `s3://${config.bucket}/${deletedKey}`;
816
+ }
817
+ if (dbConnection.type === 'sqlite') {
818
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
819
+ }
820
+ else if (dbConnection.type === 'qdrant') {
821
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
822
+ }
823
+ }
824
+ }
825
+ // Persist sync state
826
+ const currentKeys = Array.from(processedFiles);
827
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentKeys), logger, this.embeddingDimension);
828
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, lastSyncKey, `${syncStartTimestamp}`, logger, this.embeddingDimension);
829
+ logger.info(`Finished processing S3 bucket: ${config.bucket}`);
830
+ }
576
831
  async processCodeSource(config, parentLogger) {
577
832
  const logger = parentLogger.child('process');
578
833
  logger.info(`Starting processing for code source (${config.source})`);
@@ -910,6 +1165,8 @@ class Doc2Vec {
910
1165
  async fetchAndProcessZendeskTickets(config, dbConnection, logger) {
911
1166
  const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2`;
912
1167
  const auth = buffer_1.Buffer.from(`${config.email}/token:${config.api_token}`).toString('base64');
1168
+ // Capture timestamp at the start so tickets updated during sync are re-processed next run
1169
+ const syncStartDate = new Date().toISOString();
913
1170
  // Get the last run date from the database
914
1171
  const startDate = config.start_date || `${new Date().getFullYear()}-01-01`;
915
1172
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, `${startDate}T00:00:00Z`, logger);
@@ -1067,7 +1324,7 @@ class Doc2Vec {
1067
1324
  }
1068
1325
  }
1069
1326
  // Update the last run date in the database
1070
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension);
1327
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension, syncStartDate);
1071
1328
  logger.info(`Successfully processed ${totalTickets} tickets`);
1072
1329
  }
1073
1330
  async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MarkdownStore = void 0;
4
+ const pg_1 = require("pg");
5
+ /**
6
+ * Stores generated markdown for website pages in a Postgres table.
7
+ *
8
+ * Table schema (single shared table, default name `markdown_pages`):
9
+ * url TEXT PRIMARY KEY
10
+ * product_name TEXT NOT NULL
11
+ * markdown TEXT NOT NULL
12
+ * updated_at TIMESTAMPTZ DEFAULT NOW()
13
+ *
14
+ * When a website source has `markdown_store: true`, the crawl loop uses this
15
+ * store to decide whether to force-process a page that would otherwise be
16
+ * skipped by lastmod / ETag caching. If the URL is not yet in Postgres the
17
+ * page is processed regardless of cache signals, ensuring the table is fully
18
+ * populated after the first sync. On subsequent syncs only pages with
19
+ * detected changes are updated.
20
+ */
21
+ class MarkdownStore {
22
+ constructor(config, logger) {
23
+ this.logger = logger.child('markdown-store');
24
+ this.tableName = config.table_name ?? 'markdown_pages';
25
+ const poolConfig = {};
26
+ if (config.connection_string) {
27
+ poolConfig.connectionString = config.connection_string;
28
+ }
29
+ else {
30
+ if (config.host)
31
+ poolConfig.host = config.host;
32
+ if (config.port)
33
+ poolConfig.port = config.port;
34
+ if (config.database)
35
+ poolConfig.database = config.database;
36
+ if (config.user)
37
+ poolConfig.user = config.user;
38
+ if (config.password)
39
+ poolConfig.password = config.password;
40
+ }
41
+ this.pool = new pg_1.Pool(poolConfig);
42
+ }
43
+ /**
44
+ * Create the markdown table if it doesn't already exist.
45
+ */
46
+ async init() {
47
+ const query = `
48
+ CREATE TABLE IF NOT EXISTS ${this.escapeIdentifier(this.tableName)} (
49
+ url TEXT PRIMARY KEY,
50
+ product_name TEXT NOT NULL,
51
+ markdown TEXT NOT NULL,
52
+ updated_at TIMESTAMPTZ DEFAULT NOW()
53
+ );
54
+ `;
55
+ await this.pool.query(query);
56
+ this.logger.info(`Initialized Postgres markdown store (table: ${this.tableName})`);
57
+ }
58
+ /**
59
+ * Return the set of URLs that already have markdown stored for a given URL
60
+ * prefix (e.g., "https://istio.io/latest/docs/"). This is called once
61
+ * before the crawl starts so the crawler can decide which pages need
62
+ * force-processing.
63
+ */
64
+ async getUrlsWithMarkdown(urlPrefix) {
65
+ const result = await this.pool.query(`SELECT url FROM ${this.escapeIdentifier(this.tableName)} WHERE url LIKE $1`, [urlPrefix + '%']);
66
+ return new Set(result.rows.map((row) => row.url));
67
+ }
68
+ /**
69
+ * Insert or update the markdown for a URL. Called after a page is
70
+ * successfully processed (fetched + converted to markdown).
71
+ */
72
+ async upsertMarkdown(url, productName, markdown) {
73
+ const query = `
74
+ INSERT INTO ${this.escapeIdentifier(this.tableName)} (url, product_name, markdown, updated_at)
75
+ VALUES ($1, $2, $3, NOW())
76
+ ON CONFLICT (url) DO UPDATE SET
77
+ product_name = EXCLUDED.product_name,
78
+ markdown = EXCLUDED.markdown,
79
+ updated_at = NOW();
80
+ `;
81
+ await this.pool.query(query, [url, productName, markdown]);
82
+ }
83
+ /**
84
+ * Remove a URL from the store (e.g., when a HEAD request returns 404).
85
+ */
86
+ async deleteMarkdown(url) {
87
+ await this.pool.query(`DELETE FROM ${this.escapeIdentifier(this.tableName)} WHERE url = $1`, [url]);
88
+ }
89
+ /**
90
+ * Close the connection pool. Should be called once after all sources have
91
+ * been processed.
92
+ */
93
+ async close() {
94
+ await this.pool.end();
95
+ this.logger.info('Postgres markdown store connection pool closed');
96
+ }
97
+ /**
98
+ * Escape a SQL identifier (table name) to prevent injection.
99
+ * Uses double-quoting per the SQL standard.
100
+ */
101
+ escapeIdentifier(identifier) {
102
+ return '"' + identifier.replace(/"/g, '""') + '"';
103
+ }
104
+ }
105
+ exports.MarkdownStore = MarkdownStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",
@@ -29,6 +29,7 @@
29
29
  "author": "",
30
30
  "license": "ISC",
31
31
  "dependencies": {
32
+ "@aws-sdk/client-s3": "^3.1004.0",
32
33
  "@chonkiejs/core": "^0.0.7",
33
34
  "@mozilla/readability": "^0.4.4",
34
35
  "@qdrant/js-client-rest": "^1.13.0",
@@ -43,6 +44,7 @@
43
44
  "mammoth": "^1.11.0",
44
45
  "openai": "^4.20.1",
45
46
  "pdfjs-dist": "^5.3.31",
47
+ "pg": "^8.18.0",
46
48
  "puppeteer": "^24.1.1",
47
49
  "sanitize-html": "^2.11.0",
48
50
  "sqlite-vec": "0.1.7-alpha.2",
@@ -56,6 +58,7 @@
56
58
  "@types/js-yaml": "^4.0.9",
57
59
  "@types/jsdom": "^21.1.7",
58
60
  "@types/node": "^20.10.0",
61
+ "@types/pg": "^8.16.0",
59
62
  "@types/sanitize-html": "^2.9.5",
60
63
  "@types/turndown": "^5.0.4",
61
64
  "@vitest/coverage-v8": "^4.0.18",