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 +75 -2
- package/dist/content-processor.js +95 -50
- package/dist/database.js +2 -2
- package/dist/doc2vec.js +260 -3
- package/dist/markdown-store.js +105 -0
- package/package.json +4 -1
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 `'
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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",
|