doc2vec 2.4.0 → 2.6.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 +53 -1
- package/dist/content-processor.js +50 -15
- package/dist/database.js +11 -13
- package/dist/doc2vec.js +95 -15
- package/dist/markdown-store.js +105 -0
- package/dist/utils.js +4 -2
- package/package.json +3 -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.
|
|
@@ -145,6 +151,9 @@ Configuration is managed through two files:
|
|
|
145
151
|
OPENAI_API_KEY="sk-..."
|
|
146
152
|
OPENAI_MODEL="text-embedding-3-large" # Optional, defaults to text-embedding-3-large
|
|
147
153
|
|
|
154
|
+
# Optional: Embedding dimension size (defaults to 3072)
|
|
155
|
+
EMBEDDING_DIMENSION="3072"
|
|
156
|
+
|
|
148
157
|
# Required: Your Azure OpenAI credentials (if using Azure provider)
|
|
149
158
|
AZURE_OPENAI_KEY="your-azure-key"
|
|
150
159
|
AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com"
|
|
@@ -172,6 +181,7 @@ Configuration is managed through two files:
|
|
|
172
181
|
For websites (`type: 'website'`):
|
|
173
182
|
* `url`: The starting URL for crawling the documentation site.
|
|
174
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`.
|
|
175
185
|
|
|
176
186
|
For GitHub repositories (`type: 'github'`):
|
|
177
187
|
* `repo`: Repository name in the format `'owner/repo'` (e.g., `'istio/istio'`).
|
|
@@ -223,6 +233,21 @@ Configuration is managed through two files:
|
|
|
223
233
|
* `qdrant_port`: (Optional) Port for the Qdrant REST API. Defaults to `443` if `qdrant_url` starts with `https`, otherwise `6333`.
|
|
224
234
|
* `collection_name`: (Optional) Name of the Qdrant collection to use. Defaults to `<product_name>_<version>` (lowercased, spaces replaced with underscores).
|
|
225
235
|
|
|
236
|
+
Optional embedding configuration:
|
|
237
|
+
* `embedding.provider`: Provider for embeddings (`openai` or `azure`).
|
|
238
|
+
* `embedding.dimension`: Embedding vector size. Defaults to `3072` when not set.
|
|
239
|
+
|
|
240
|
+
Optional Postgres markdown store (top-level):
|
|
241
|
+
* `markdown_store.connection_string`: (Optional) Full Postgres connection string (e.g., `'postgres://user:pass@host:5432/db'`). Takes priority over individual fields.
|
|
242
|
+
* `markdown_store.host`: (Optional) Postgres host.
|
|
243
|
+
* `markdown_store.port`: (Optional) Postgres port.
|
|
244
|
+
* `markdown_store.database`: (Optional) Postgres database name.
|
|
245
|
+
* `markdown_store.user`: (Optional) Postgres user.
|
|
246
|
+
* `markdown_store.password`: (Optional) Postgres password. Supports `${PG_PASSWORD}` env var substitution.
|
|
247
|
+
* `markdown_store.table_name`: (Optional) Table name. Defaults to `'markdown_pages'`.
|
|
248
|
+
|
|
249
|
+
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.
|
|
250
|
+
|
|
226
251
|
**Example (`config.yaml`):**
|
|
227
252
|
```yaml
|
|
228
253
|
# Optional: Configure embedding provider
|
|
@@ -230,6 +255,7 @@ Configuration is managed through two files:
|
|
|
230
255
|
# Defaults to OpenAI if not specified
|
|
231
256
|
embedding:
|
|
232
257
|
provider: 'openai' # or 'azure'
|
|
258
|
+
dimension: 3072 # Optional, defaults to 3072
|
|
233
259
|
openai:
|
|
234
260
|
api_key: '${OPENAI_API_KEY}' # Optional, uses env var by default
|
|
235
261
|
model: 'text-embedding-3-large' # Optional, defaults to text-embedding-3-large
|
|
@@ -240,13 +266,25 @@ Configuration is managed through two files:
|
|
|
240
266
|
# deployment_name: 'text-embedding-3-large'
|
|
241
267
|
# api_version: '2024-10-21' # Optional
|
|
242
268
|
|
|
269
|
+
# Optional: Store generated markdown in Postgres
|
|
270
|
+
# markdown_store:
|
|
271
|
+
# connection_string: 'postgres://user:pass@host:5432/db'
|
|
272
|
+
# # OR use individual fields:
|
|
273
|
+
# # host: 'localhost'
|
|
274
|
+
# # port: 5432
|
|
275
|
+
# # database: 'doc2vec'
|
|
276
|
+
# # user: 'myuser'
|
|
277
|
+
# # password: '${PG_PASSWORD}'
|
|
278
|
+
# # table_name: 'markdown_pages' # Optional, defaults to 'markdown_pages'
|
|
279
|
+
|
|
243
280
|
sources:
|
|
244
|
-
# Website source example
|
|
281
|
+
# Website source example (with markdown store enabled)
|
|
245
282
|
- type: 'website'
|
|
246
283
|
product_name: 'argo'
|
|
247
284
|
version: 'stable'
|
|
248
285
|
url: 'https://argo-cd.readthedocs.io/en/stable/'
|
|
249
286
|
sitemap_url: 'https://argo-cd.readthedocs.io/en/stable/sitemap.xml'
|
|
287
|
+
markdown_store: true # Store generated markdown in Postgres
|
|
250
288
|
max_size: 1048576
|
|
251
289
|
database_config:
|
|
252
290
|
type: 'sqlite'
|
|
@@ -540,6 +578,20 @@ If you don't specify a config path, it will look for config.yaml in the current
|
|
|
540
578
|
|
|
541
579
|
## Recent Changes
|
|
542
580
|
|
|
581
|
+
### Postgres Markdown Store
|
|
582
|
+
- **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
|
|
583
|
+
- **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
|
|
584
|
+
- **Per-source opt-in:** Enable per website source with `markdown_store: true` (disabled by default)
|
|
585
|
+
- **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
|
|
586
|
+
- **Change-only updates:** On subsequent syncs, only pages with detected content changes (via lastmod/ETag) have their Postgres rows updated
|
|
587
|
+
- **404 cleanup:** Pages that return 404 during HEAD checks are automatically removed from the Postgres store
|
|
588
|
+
|
|
589
|
+
### Incomplete Sync Recovery
|
|
590
|
+
- **New feature:** Tracks whether a full sync has ever completed successfully for each website source via a `sync_complete:<url_prefix>` metadata key
|
|
591
|
+
- **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
|
|
592
|
+
- **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
|
|
593
|
+
- **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
|
|
594
|
+
|
|
543
595
|
### Multi-Layer Change Detection for Websites
|
|
544
596
|
- **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.
|
|
545
597
|
- **`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
|
}
|
|
@@ -586,7 +621,7 @@ class ContentProcessor {
|
|
|
586
621
|
logger.debug(`Finding links on page ${url}`);
|
|
587
622
|
let newLinksFound = 0;
|
|
588
623
|
for (const href of result.links) {
|
|
589
|
-
const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
|
|
624
|
+
const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks, logger);
|
|
590
625
|
if (fullUrl.startsWith(sourceConfig.url)) {
|
|
591
626
|
addReferrer(fullUrl, pageUrlForLinks);
|
|
592
627
|
if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
|
|
@@ -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
|
package/dist/database.js
CHANGED
|
@@ -44,7 +44,7 @@ const sqliteVec = __importStar(require("sqlite-vec"));
|
|
|
44
44
|
const js_client_rest_1 = require("@qdrant/js-client-rest");
|
|
45
45
|
const utils_1 = require("./utils");
|
|
46
46
|
class DatabaseManager {
|
|
47
|
-
static async initDatabase(config, parentLogger) {
|
|
47
|
+
static async initDatabase(config, parentLogger, embeddingDimension) {
|
|
48
48
|
const logger = parentLogger.child('database');
|
|
49
49
|
const dbConfig = config.database_config;
|
|
50
50
|
if (dbConfig.type === 'sqlite') {
|
|
@@ -53,10 +53,10 @@ class DatabaseManager {
|
|
|
53
53
|
logger.info(`Opening SQLite database at ${dbPath}`);
|
|
54
54
|
const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
|
|
55
55
|
sqliteVec.load(db);
|
|
56
|
-
logger.debug(`Creating vec_items table if it doesn't exist`);
|
|
56
|
+
logger.debug(`Creating vec_items table if it doesn't exist (dimension: ${embeddingDimension})`);
|
|
57
57
|
db.exec(`
|
|
58
58
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
|
|
59
|
-
embedding FLOAT[
|
|
59
|
+
embedding FLOAT[${embeddingDimension}],
|
|
60
60
|
product_name TEXT,
|
|
61
61
|
version TEXT,
|
|
62
62
|
branch TEXT,
|
|
@@ -81,7 +81,7 @@ class DatabaseManager {
|
|
|
81
81
|
const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
|
|
82
82
|
logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
|
|
83
83
|
const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
|
|
84
|
-
await this.createCollectionQdrant(qdrantClient, collectionName, logger);
|
|
84
|
+
await this.createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension);
|
|
85
85
|
logger.info(`Qdrant connection established successfully`);
|
|
86
86
|
return { client: qdrantClient, collectionName, type: 'qdrant' };
|
|
87
87
|
}
|
|
@@ -91,7 +91,7 @@ class DatabaseManager {
|
|
|
91
91
|
throw new Error(errMsg);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
static async createCollectionQdrant(qdrantClient, collectionName, logger) {
|
|
94
|
+
static async createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension) {
|
|
95
95
|
try {
|
|
96
96
|
logger.debug(`Checking if collection ${collectionName} exists`);
|
|
97
97
|
const collections = await qdrantClient.getCollections();
|
|
@@ -100,10 +100,10 @@ class DatabaseManager {
|
|
|
100
100
|
logger.info(`Collection ${collectionName} already exists`);
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
|
-
logger.info(`Creating new collection ${collectionName}`);
|
|
103
|
+
logger.info(`Creating new collection ${collectionName} with dimension ${embeddingDimension}`);
|
|
104
104
|
await qdrantClient.createCollection(collectionName, {
|
|
105
105
|
vectors: {
|
|
106
|
-
size:
|
|
106
|
+
size: embeddingDimension,
|
|
107
107
|
distance: "Cosine",
|
|
108
108
|
},
|
|
109
109
|
});
|
|
@@ -181,7 +181,7 @@ class DatabaseManager {
|
|
|
181
181
|
}
|
|
182
182
|
return defaultValue;
|
|
183
183
|
}
|
|
184
|
-
static async setMetadataValue(dbConnection, key, value, logger) {
|
|
184
|
+
static async setMetadataValue(dbConnection, key, value, logger, embeddingDimension) {
|
|
185
185
|
try {
|
|
186
186
|
if (dbConnection.type === 'sqlite') {
|
|
187
187
|
const stmt = dbConnection.db.prepare(`
|
|
@@ -193,8 +193,7 @@ class DatabaseManager {
|
|
|
193
193
|
}
|
|
194
194
|
else if (dbConnection.type === 'qdrant') {
|
|
195
195
|
const metadataUUID = utils_1.Utils.generateMetadataUUID(key);
|
|
196
|
-
const
|
|
197
|
-
const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
|
|
196
|
+
const dummyEmbedding = new Array(embeddingDimension).fill(0);
|
|
198
197
|
const metadataPoint = {
|
|
199
198
|
id: metadataUUID,
|
|
200
199
|
vector: dummyEmbedding,
|
|
@@ -258,7 +257,7 @@ class DatabaseManager {
|
|
|
258
257
|
logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
|
|
259
258
|
return defaultDate;
|
|
260
259
|
}
|
|
261
|
-
static async updateLastRunDate(dbConnection, repo, logger) {
|
|
260
|
+
static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension) {
|
|
262
261
|
const now = new Date().toISOString();
|
|
263
262
|
try {
|
|
264
263
|
if (dbConnection.type === 'sqlite') {
|
|
@@ -276,8 +275,7 @@ class DatabaseManager {
|
|
|
276
275
|
const metadataKey = `last_run_${repo.replace('/', '_')}`;
|
|
277
276
|
logger.debug(`Using UUID: ${metadataUUID} for metadata`);
|
|
278
277
|
// Generate a dummy embedding (all zeros)
|
|
279
|
-
const
|
|
280
|
-
const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
|
|
278
|
+
const dummyEmbedding = new Array(embeddingDimension).fill(0);
|
|
281
279
|
// Create a point with special metadata payload
|
|
282
280
|
const metadataPoint = {
|
|
283
281
|
id: metadataUUID,
|
package/dist/doc2vec.js
CHANGED
|
@@ -53,6 +53,7 @@ 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 markdown_store_1 = require("./markdown-store");
|
|
56
57
|
const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
57
58
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
58
59
|
dotenv.config();
|
|
@@ -72,6 +73,7 @@ class Doc2Vec {
|
|
|
72
73
|
// Check environment variable if not specified in config
|
|
73
74
|
const embeddingProvider = this.config.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'openai';
|
|
74
75
|
const embeddingConfig = this.config.embedding || { provider: embeddingProvider };
|
|
76
|
+
this.embeddingDimension = this.resolveEmbeddingDimension(embeddingConfig);
|
|
75
77
|
if (embeddingProvider === 'azure') {
|
|
76
78
|
const azureApiKey = embeddingConfig.azure?.api_key || process.env.AZURE_OPENAI_KEY;
|
|
77
79
|
const azureEndpoint = embeddingConfig.azure?.endpoint || process.env.AZURE_OPENAI_ENDPOINT;
|
|
@@ -88,7 +90,7 @@ class Doc2Vec {
|
|
|
88
90
|
apiVersion: azureApiVersion,
|
|
89
91
|
});
|
|
90
92
|
this.embeddingModel = azureDeploymentName;
|
|
91
|
-
this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName}`);
|
|
93
|
+
this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName} (${this.embeddingDimension} dimensions)`);
|
|
92
94
|
}
|
|
93
95
|
else {
|
|
94
96
|
const openaiApiKey = embeddingConfig.openai?.api_key || process.env.OPENAI_API_KEY;
|
|
@@ -99,9 +101,13 @@ class Doc2Vec {
|
|
|
99
101
|
}
|
|
100
102
|
this.openai = new openai_1.OpenAI({ apiKey: openaiApiKey });
|
|
101
103
|
this.embeddingModel = openaiModel;
|
|
102
|
-
this.logger.info(`Using OpenAI with model: ${openaiModel}`);
|
|
104
|
+
this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
|
|
103
105
|
}
|
|
104
106
|
this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
|
|
107
|
+
// Initialize Postgres markdown store if configured
|
|
108
|
+
if (this.config.markdown_store) {
|
|
109
|
+
this.markdownStore = new markdown_store_1.MarkdownStore(this.config.markdown_store, this.logger);
|
|
110
|
+
}
|
|
105
111
|
}
|
|
106
112
|
loadConfig(configPath) {
|
|
107
113
|
try {
|
|
@@ -144,7 +150,26 @@ class Doc2Vec {
|
|
|
144
150
|
process.exit(1);
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
resolveEmbeddingDimension(embeddingConfig) {
|
|
154
|
+
const defaultDimension = 3072;
|
|
155
|
+
const rawConfigValue = embeddingConfig?.dimension;
|
|
156
|
+
const rawEnvValue = process.env.EMBEDDING_DIMENSION;
|
|
157
|
+
const candidate = rawConfigValue ?? (rawEnvValue ? Number(rawEnvValue) : undefined);
|
|
158
|
+
if (candidate === undefined) {
|
|
159
|
+
return defaultDimension;
|
|
160
|
+
}
|
|
161
|
+
const parsedValue = typeof candidate === 'string' ? Number(candidate) : candidate;
|
|
162
|
+
if (!Number.isFinite(parsedValue) || parsedValue <= 0 || !Number.isInteger(parsedValue)) {
|
|
163
|
+
this.logger.warn(`Invalid embedding dimension provided (${candidate}), falling back to ${defaultDimension}`);
|
|
164
|
+
return defaultDimension;
|
|
165
|
+
}
|
|
166
|
+
return parsedValue;
|
|
167
|
+
}
|
|
147
168
|
async run() {
|
|
169
|
+
// Initialize Postgres markdown store table if configured
|
|
170
|
+
if (this.markdownStore) {
|
|
171
|
+
await this.markdownStore.init();
|
|
172
|
+
}
|
|
148
173
|
this.logger.section('PROCESSING SOURCES');
|
|
149
174
|
for (const sourceConfig of this.config.sources) {
|
|
150
175
|
const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
|
|
@@ -168,6 +193,10 @@ class Doc2Vec {
|
|
|
168
193
|
sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
|
|
169
194
|
}
|
|
170
195
|
}
|
|
196
|
+
// Close the Postgres markdown store connection pool
|
|
197
|
+
if (this.markdownStore) {
|
|
198
|
+
await this.markdownStore.close();
|
|
199
|
+
}
|
|
171
200
|
this.logger.section('PROCESSING COMPLETE');
|
|
172
201
|
}
|
|
173
202
|
async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
|
|
@@ -373,13 +402,13 @@ class Doc2Vec {
|
|
|
373
402
|
await processIssue(issues[i]);
|
|
374
403
|
}
|
|
375
404
|
// Update the last run date in the database after processing all issues
|
|
376
|
-
await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
|
|
405
|
+
await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension);
|
|
377
406
|
logger.info(`Successfully processed ${issues.length} issues`);
|
|
378
407
|
}
|
|
379
408
|
async processGithubRepo(config, parentLogger) {
|
|
380
409
|
const logger = parentLogger.child('process');
|
|
381
410
|
logger.info(`Starting processing for GitHub repo: ${config.repo}`);
|
|
382
|
-
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
411
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
|
|
383
412
|
// Initialize metadata storage
|
|
384
413
|
await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
|
|
385
414
|
logger.section('GITHUB ISSUES');
|
|
@@ -390,7 +419,7 @@ class Doc2Vec {
|
|
|
390
419
|
async processWebsite(config, parentLogger) {
|
|
391
420
|
const logger = parentLogger.child('process');
|
|
392
421
|
logger.info(`Starting processing for website: ${config.url}`);
|
|
393
|
-
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
422
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
|
|
394
423
|
await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
|
|
395
424
|
const validChunkIds = new Set();
|
|
396
425
|
const visitedUrls = new Set();
|
|
@@ -420,7 +449,7 @@ class Doc2Vec {
|
|
|
420
449
|
return database_1.DatabaseManager.getMetadataValue(dbConnection, `etag:${url}`, undefined, logger);
|
|
421
450
|
},
|
|
422
451
|
set: async (url, etag) => {
|
|
423
|
-
await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger);
|
|
452
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger, this.embeddingDimension);
|
|
424
453
|
},
|
|
425
454
|
};
|
|
426
455
|
const lastmodStore = {
|
|
@@ -428,9 +457,31 @@ class Doc2Vec {
|
|
|
428
457
|
return database_1.DatabaseManager.getMetadataValue(dbConnection, `lastmod:${url}`, undefined, logger);
|
|
429
458
|
},
|
|
430
459
|
set: async (url, lastmod) => {
|
|
431
|
-
await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger);
|
|
460
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
|
|
432
461
|
},
|
|
433
462
|
};
|
|
463
|
+
// If Postgres markdown store is enabled for this source, load URLs that
|
|
464
|
+
// already have markdown stored. URLs NOT in this set will bypass
|
|
465
|
+
// lastmod/ETag skip logic so that the store is fully populated on the
|
|
466
|
+
// first sync.
|
|
467
|
+
const useMarkdownStore = config.markdown_store === true && this.markdownStore != null;
|
|
468
|
+
let markdownStoreUrls;
|
|
469
|
+
if (useMarkdownStore) {
|
|
470
|
+
markdownStoreUrls = await this.markdownStore.getUrlsWithMarkdown(urlPrefix);
|
|
471
|
+
logger.info(`Markdown store: ${markdownStoreUrls.size} URLs already stored for prefix ${urlPrefix}`);
|
|
472
|
+
}
|
|
473
|
+
// Check whether a full sync has ever completed successfully for this
|
|
474
|
+
// source. If not, bypass all lastmod/ETag skip logic so that every
|
|
475
|
+
// page is processed at least once. This handles the case where a
|
|
476
|
+
// previous sync was interrupted (killed mid-crawl) — the stored
|
|
477
|
+
// ETags/lastmods from the partial run would otherwise cause the
|
|
478
|
+
// remaining pages to be skipped indefinitely.
|
|
479
|
+
const syncCompleteKey = `sync_complete:${urlPrefix}`;
|
|
480
|
+
const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
|
|
481
|
+
const forceFullSync = syncCompleteValue !== 'true';
|
|
482
|
+
if (forceFullSync) {
|
|
483
|
+
logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
|
|
484
|
+
}
|
|
434
485
|
const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
|
|
435
486
|
visitedUrls.add(url);
|
|
436
487
|
logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
|
|
@@ -441,21 +492,50 @@ class Doc2Vec {
|
|
|
441
492
|
validChunkIds.add(chunk.metadata.chunk_id);
|
|
442
493
|
}
|
|
443
494
|
await this.processChunksForUrl(chunks, url, dbConnection, logger);
|
|
495
|
+
// Store the generated markdown in Postgres
|
|
496
|
+
if (useMarkdownStore) {
|
|
497
|
+
try {
|
|
498
|
+
await this.markdownStore.upsertMarkdown(url, config.product_name, content);
|
|
499
|
+
}
|
|
500
|
+
catch (pgError) {
|
|
501
|
+
logger.error(`Failed to store markdown in Postgres for ${url}:`, pgError);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
444
504
|
return true;
|
|
445
505
|
}
|
|
446
506
|
catch (error) {
|
|
447
507
|
logger.error(`Error during chunking or embedding for ${url}:`, error);
|
|
448
508
|
return false;
|
|
449
509
|
}
|
|
450
|
-
}, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
|
|
510
|
+
}, logger, visitedUrls, { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync });
|
|
451
511
|
this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
|
|
452
512
|
this.writeBrokenLinksReport();
|
|
453
513
|
logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
|
|
454
514
|
logger.section('CLEANUP');
|
|
515
|
+
// Remove 404 URLs from the Postgres markdown store
|
|
516
|
+
if (useMarkdownStore && crawlResult.notFoundUrls && crawlResult.notFoundUrls.size > 0) {
|
|
517
|
+
logger.info(`Removing ${crawlResult.notFoundUrls.size} not-found URLs from markdown store`);
|
|
518
|
+
for (const url of crawlResult.notFoundUrls) {
|
|
519
|
+
try {
|
|
520
|
+
await this.markdownStore.deleteMarkdown(url);
|
|
521
|
+
}
|
|
522
|
+
catch (pgError) {
|
|
523
|
+
logger.error(`Failed to delete markdown from Postgres for ${url}:`, pgError);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
455
527
|
if (crawlResult.hasNetworkErrors) {
|
|
456
528
|
logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
|
|
457
529
|
}
|
|
458
530
|
else {
|
|
531
|
+
// Mark this source as having completed a full sync. On subsequent
|
|
532
|
+
// runs, lastmod/ETag skip logic will function normally. If the
|
|
533
|
+
// process is killed before reaching this point, the flag stays
|
|
534
|
+
// unset and the next run will force-process all pages again.
|
|
535
|
+
if (forceFullSync) {
|
|
536
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
|
|
537
|
+
logger.info('Full sync completed successfully — subsequent runs will use normal caching');
|
|
538
|
+
}
|
|
459
539
|
if (dbConnection.type === 'sqlite') {
|
|
460
540
|
logger.info(`Running SQLite cleanup for ${urlPrefix}`);
|
|
461
541
|
database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
|
|
@@ -502,7 +582,7 @@ class Doc2Vec {
|
|
|
502
582
|
async processLocalDirectory(config, parentLogger) {
|
|
503
583
|
const logger = parentLogger.child('process');
|
|
504
584
|
logger.info(`Starting processing for local directory: ${config.path}`);
|
|
505
|
-
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
585
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
|
|
506
586
|
const validChunkIds = new Set();
|
|
507
587
|
const processedFiles = new Set();
|
|
508
588
|
logger.section('FILE SCANNING AND EMBEDDING');
|
|
@@ -560,7 +640,7 @@ class Doc2Vec {
|
|
|
560
640
|
async processCodeSource(config, parentLogger) {
|
|
561
641
|
const logger = parentLogger.child('process');
|
|
562
642
|
logger.info(`Starting processing for code source (${config.source})`);
|
|
563
|
-
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
643
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
|
|
564
644
|
const validChunkIds = new Set();
|
|
565
645
|
const processedFiles = new Set();
|
|
566
646
|
let basePath;
|
|
@@ -692,10 +772,10 @@ class Doc2Vec {
|
|
|
692
772
|
await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
|
|
693
773
|
}
|
|
694
774
|
}
|
|
695
|
-
await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger);
|
|
775
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
|
|
696
776
|
if (lastMtimeKey) {
|
|
697
777
|
const nextMtime = maxObservedMtime > 0 ? maxObservedMtime : Date.now();
|
|
698
|
-
await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger);
|
|
778
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger, this.embeddingDimension);
|
|
699
779
|
}
|
|
700
780
|
}
|
|
701
781
|
}
|
|
@@ -713,7 +793,7 @@ class Doc2Vec {
|
|
|
713
793
|
const headSha = await this.getRepoHeadSha(basePath, logger);
|
|
714
794
|
if (headSha) {
|
|
715
795
|
const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
|
|
716
|
-
await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger);
|
|
796
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger, this.embeddingDimension);
|
|
717
797
|
}
|
|
718
798
|
}
|
|
719
799
|
if (tempDir) {
|
|
@@ -876,7 +956,7 @@ class Doc2Vec {
|
|
|
876
956
|
async processZendesk(config, parentLogger) {
|
|
877
957
|
const logger = parentLogger.child('process');
|
|
878
958
|
logger.info(`Starting processing for Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
|
|
879
|
-
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
|
|
959
|
+
const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
|
|
880
960
|
// Initialize metadata storage
|
|
881
961
|
await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
|
|
882
962
|
const fetchTickets = config.fetch_tickets !== false; // default true
|
|
@@ -1051,7 +1131,7 @@ class Doc2Vec {
|
|
|
1051
1131
|
}
|
|
1052
1132
|
}
|
|
1053
1133
|
// Update the last run date in the database
|
|
1054
|
-
await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger);
|
|
1134
|
+
await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension);
|
|
1055
1135
|
logger.info(`Successfully processed ${totalTickets} tickets`);
|
|
1056
1136
|
}
|
|
1057
1137
|
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/dist/utils.js
CHANGED
|
@@ -69,12 +69,14 @@ class Utils {
|
|
|
69
69
|
return url;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
static buildUrl(href, currentUrl) {
|
|
72
|
+
static buildUrl(href, currentUrl, logger) {
|
|
73
73
|
try {
|
|
74
74
|
return new URL(href, currentUrl).toString();
|
|
75
75
|
}
|
|
76
76
|
catch (error) {
|
|
77
|
-
|
|
77
|
+
if (logger) {
|
|
78
|
+
logger.warn(`Invalid URL found: ${href}`);
|
|
79
|
+
}
|
|
78
80
|
return '';
|
|
79
81
|
}
|
|
80
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc2vec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "",
|
|
6
6
|
"main": "dist/doc2vec.js",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"mammoth": "^1.11.0",
|
|
44
44
|
"openai": "^4.20.1",
|
|
45
45
|
"pdfjs-dist": "^5.3.31",
|
|
46
|
+
"pg": "^8.18.0",
|
|
46
47
|
"puppeteer": "^24.1.1",
|
|
47
48
|
"sanitize-html": "^2.11.0",
|
|
48
49
|
"sqlite-vec": "0.1.7-alpha.2",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"@types/js-yaml": "^4.0.9",
|
|
57
58
|
"@types/jsdom": "^21.1.7",
|
|
58
59
|
"@types/node": "^20.10.0",
|
|
60
|
+
"@types/pg": "^8.16.0",
|
|
59
61
|
"@types/sanitize-html": "^2.9.5",
|
|
60
62
|
"@types/turndown": "^5.0.4",
|
|
61
63
|
"@vitest/coverage-v8": "^4.0.18",
|