doc2vec 2.10.6 → 2.11.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 +98 -0
- package/dist/content-processor.js +122 -22
- package/dist/doc2vec.js +101 -24
- package/dist/logger.js +89 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
|
|
|
52
52
|
* **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
|
|
53
53
|
* **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
|
|
54
54
|
* **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
|
|
55
|
+
* **[Controller Mode](#controller-mode):** Optionally runs as a long-lived controller that schedules sync jobs from multiple config files (cron `schedule` field), persists run history and statistics in Postgres, and serves a web UI with live log streaming — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
|
|
55
56
|
* **Structured Logging:** Uses a custom logger (`logger.ts`) with levels, timestamps, colors, progress bars, and child loggers for clear execution monitoring.
|
|
56
57
|
|
|
57
58
|
## Chunk Metadata & Page Reconstruction
|
|
@@ -437,6 +438,103 @@ The script will then:
|
|
|
437
438
|
7. Cleanup obsolete chunks.
|
|
438
439
|
8. Output detailed logs.
|
|
439
440
|
|
|
441
|
+
## Controller Mode
|
|
442
|
+
|
|
443
|
+
Besides the one-shot sync, doc2vec can run as a **long-lived controller** that schedules sync jobs, records run history and statistics in Postgres, and serves a web UI for monitoring and managing configs:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
doc2vec controller --database-url postgres://user:pass@host:5432/doc2vec ./configs/
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Open http://localhost:8080 to see the dashboard: configs with their schedule and next run, run history with per-source statistics, and live log streaming for running jobs.
|
|
450
|
+
|
|
451
|
+
### Scheduling
|
|
452
|
+
|
|
453
|
+
Add a top-level cron `schedule` (and optionally a display `name`) to a config file, and the controller runs it automatically:
|
|
454
|
+
|
|
455
|
+
```yaml
|
|
456
|
+
name: product-docs
|
|
457
|
+
schedule: "0 2 * * *" # every day at 02:00
|
|
458
|
+
sources:
|
|
459
|
+
- type: website
|
|
460
|
+
...
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Configs without a `schedule` are still listed in the UI and can be triggered manually with **Run now**. Each run executes `doc2vec run <config.yaml>` as a child process; overlapping runs of the same config are skipped, and `--max-parallel` (default 1) caps how many sync jobs run at once — keep it low, since website sources launch a headless Chromium.
|
|
464
|
+
|
|
465
|
+
### Flags
|
|
466
|
+
|
|
467
|
+
| Flag | Default | Description |
|
|
468
|
+
|------|---------|-------------|
|
|
469
|
+
| `--database-url <url>` | `DATABASE_URL` env | Postgres connection string (required) |
|
|
470
|
+
| `--port <n>` | `8080` (or `PORT`) | HTTP port for the API and UI |
|
|
471
|
+
| `--read-write` | off (read-only) | Allow creating/editing/deleting configs from the UI |
|
|
472
|
+
| `--config-dir <dir>` | — | Where UI-created configs are written (required with `--read-write`) |
|
|
473
|
+
| `--max-parallel <n>` | `1` | Max concurrent sync jobs |
|
|
474
|
+
| `--reload-interval <s>` | `30` | How often config files are re-read for changes |
|
|
475
|
+
| `--log-retention-days <n>` | `14` | Run logs older than this are pruned (run history is kept) |
|
|
476
|
+
| `--slack-webhook-url <url>` | `SLACK_WEBHOOK_URL` env | Slack incoming webhook — post a message when a run finishes |
|
|
477
|
+
| `--slack-notify <mode>` | `all` | `all` or `failures` (failures also covers canceled runs) |
|
|
478
|
+
| `--public-url <url>` | `PUBLIC_URL` env | Externally reachable base URL, used for "view run" links in notifications |
|
|
479
|
+
|
|
480
|
+
Positional arguments are config files and/or directories (directories are scanned for `*.yaml`/`*.yml`, and re-scanned on every reload).
|
|
481
|
+
|
|
482
|
+
### Slack notifications
|
|
483
|
+
|
|
484
|
+
With `--slack-webhook-url` (or `SLACK_WEBHOOK_URL`) set, the controller posts to a [Slack incoming webhook](https://api.slack.com/messaging/webhooks) whenever a run reaches a terminal state — ✅ succeeded, ❌ failed (naming the failing sources and their errors), or ⚠️ canceled. Overlap-`skipped` runs are not notified. Set `--slack-notify failures` to silence successes, and `--public-url https://doc2vec.example.com` to get clickable "view run" links.
|
|
485
|
+
|
|
486
|
+
### Read-only vs read-write
|
|
487
|
+
|
|
488
|
+
- **Read-only** (default): configs come solely from the files passed on the command line — ideal for Kubernetes, where they live in a ConfigMap. The UI can view configs, trigger runs, and browse history/stats, but not modify anything. ConfigMap updates are picked up automatically via content-hash polling.
|
|
489
|
+
- **Read-write** (`--read-write --config-dir ./configs`): the UI can also create, edit, and delete config YAML files. Files stay the source of truth on disk; concurrent edits are protected by a content-hash check.
|
|
490
|
+
|
|
491
|
+
In both modes the UI always shows raw YAML: `${ENV_VAR}` secret placeholders are **never** resolved outside the sync job itself.
|
|
492
|
+
|
|
493
|
+
### Kubernetes example
|
|
494
|
+
|
|
495
|
+
```yaml
|
|
496
|
+
apiVersion: apps/v1
|
|
497
|
+
kind: Deployment
|
|
498
|
+
metadata:
|
|
499
|
+
name: doc2vec-controller
|
|
500
|
+
spec:
|
|
501
|
+
replicas: 1 # keep a single replica: the scheduler is not distributed
|
|
502
|
+
selector:
|
|
503
|
+
matchLabels: { app: doc2vec-controller }
|
|
504
|
+
template:
|
|
505
|
+
metadata:
|
|
506
|
+
labels: { app: doc2vec-controller }
|
|
507
|
+
spec:
|
|
508
|
+
containers:
|
|
509
|
+
- name: controller
|
|
510
|
+
image: ghcr.io/kagent-dev/doc2vec:latest
|
|
511
|
+
command: ["node", "dist/doc2vec.js", "controller", "/etc/doc2vec"]
|
|
512
|
+
ports:
|
|
513
|
+
- containerPort: 8080
|
|
514
|
+
env:
|
|
515
|
+
- name: DATABASE_URL
|
|
516
|
+
valueFrom:
|
|
517
|
+
secretKeyRef: { name: doc2vec-secrets, key: database-url }
|
|
518
|
+
- name: OPENAI_API_KEY
|
|
519
|
+
valueFrom:
|
|
520
|
+
secretKeyRef: { name: doc2vec-secrets, key: openai-api-key }
|
|
521
|
+
volumeMounts:
|
|
522
|
+
- name: configs
|
|
523
|
+
mountPath: /etc/doc2vec
|
|
524
|
+
resources:
|
|
525
|
+
requests: { memory: 1Gi }
|
|
526
|
+
limits: { memory: 4Gi } # website sources launch headless Chromium
|
|
527
|
+
volumes:
|
|
528
|
+
- name: configs
|
|
529
|
+
configMap: { name: doc2vec-configs }
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
The controller detects ConfigMap updates without a restart. Health endpoint: `GET /api/health`.
|
|
533
|
+
|
|
534
|
+
### API
|
|
535
|
+
|
|
536
|
+
The UI is backed by a small REST API you can also use directly: `GET /api/configs`, `POST /api/configs/:id/run`, `GET /api/runs?configId=`, `GET /api/runs/:id/logs`, `POST /api/runs/:id/cancel`, `GET /api/configs/:id/stats?days=30`, plus server-sent events at `GET /api/events` and `GET /api/runs/:id/logs/stream`.
|
|
537
|
+
|
|
440
538
|
## Database Options
|
|
441
539
|
|
|
442
540
|
### SQLite (`database_config.type: 'sqlite'`)
|
|
@@ -36,7 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.ContentProcessor = void 0;
|
|
39
|
+
exports.ContentProcessor = exports.BrowserLaunchError = void 0;
|
|
40
|
+
exports.resolveBrowserExecutablePath = resolveBrowserExecutablePath;
|
|
40
41
|
const readability_1 = require("@mozilla/readability");
|
|
41
42
|
const axios_1 = __importDefault(require("axios"));
|
|
42
43
|
const cheerio_1 = require("cheerio");
|
|
@@ -48,6 +49,97 @@ const fs = __importStar(require("fs"));
|
|
|
48
49
|
const path = __importStar(require("path"));
|
|
49
50
|
const utils_1 = require("./utils");
|
|
50
51
|
const code_chunker_1 = require("./code-chunker");
|
|
52
|
+
/**
|
|
53
|
+
* Thrown when the browser cannot be launched at all (even after a retry).
|
|
54
|
+
* Without a browser no page in the crawl can be processed, so this aborts the
|
|
55
|
+
* whole website source — the run is then reported as failed instead of
|
|
56
|
+
* "succeeding" with an error on every URL.
|
|
57
|
+
*/
|
|
58
|
+
class BrowserLaunchError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'BrowserLaunchError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.BrowserLaunchError = BrowserLaunchError;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve which browser binary to launch, in order of preference:
|
|
67
|
+
* 1. PUPPETEER_EXECUTABLE_PATH (explicit override)
|
|
68
|
+
* 2. Puppeteer's own downloaded Chrome for Testing (version-matched, the
|
|
69
|
+
* reliable choice — Debian's chromium package has shipped builds that
|
|
70
|
+
* crash at startup in containers, e.g. 150.0.7871 SIGTRAPs immediately)
|
|
71
|
+
* 3. A system chromium, as the fallback (also covers linux/arm64, where
|
|
72
|
+
* Chrome for Testing is not published)
|
|
73
|
+
* 4. undefined — let puppeteer decide
|
|
74
|
+
*/
|
|
75
|
+
function resolveBrowserExecutablePath() {
|
|
76
|
+
const override = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
77
|
+
if (override)
|
|
78
|
+
return override;
|
|
79
|
+
try {
|
|
80
|
+
const bundled = puppeteer_1.default.executablePath();
|
|
81
|
+
if (bundled && fs.existsSync(bundled))
|
|
82
|
+
return bundled;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// no browser downloaded for this platform
|
|
86
|
+
}
|
|
87
|
+
if (fs.existsSync('/usr/bin/chromium'))
|
|
88
|
+
return '/usr/bin/chromium';
|
|
89
|
+
if (fs.existsSync('/usr/bin/chromium-browser'))
|
|
90
|
+
return '/usr/bin/chromium-browser';
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort snapshot of process + container memory, for diagnosing browser
|
|
95
|
+
* launch failures (the usual cause in Kubernetes is the pod's memory cgroup —
|
|
96
|
+
* Chromium forks, then its renderers are immediately OOM-killed).
|
|
97
|
+
*/
|
|
98
|
+
function memoryDiagnostics() {
|
|
99
|
+
const gib = (bytes) => `${(bytes / 1024 ** 3).toFixed(2)}GiB`;
|
|
100
|
+
const parts = [`node rss ${gib(process.memoryUsage().rss)}`];
|
|
101
|
+
try {
|
|
102
|
+
// cgroup v2, else v1 — absent entirely outside Linux/containers
|
|
103
|
+
let current;
|
|
104
|
+
let max;
|
|
105
|
+
if (fs.existsSync('/sys/fs/cgroup/memory.current')) {
|
|
106
|
+
current = fs.readFileSync('/sys/fs/cgroup/memory.current', 'utf8').trim();
|
|
107
|
+
max = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
|
|
108
|
+
}
|
|
109
|
+
else if (fs.existsSync('/sys/fs/cgroup/memory/memory.usage_in_bytes')) {
|
|
110
|
+
current = fs.readFileSync('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'utf8').trim();
|
|
111
|
+
max = fs.readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf8').trim();
|
|
112
|
+
}
|
|
113
|
+
if (current !== undefined && max !== undefined) {
|
|
114
|
+
const unlimited = max === 'max' || Number(max) > 2 ** 60;
|
|
115
|
+
parts.push(`cgroup ${gib(Number(current))} used / ${unlimited ? 'no limit' : gib(Number(max))}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// diagnostics only — never fail because of them
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
// /proc/meminfo reflects the NODE in Kubernetes — catches host-level pressure
|
|
123
|
+
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
124
|
+
const available = meminfo.match(/^MemAvailable:\s+(\d+) kB/m);
|
|
125
|
+
if (available) {
|
|
126
|
+
parts.push(`node available ${gib(Number(available[1]) * 1024)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// not Linux
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
// Chromium writes its profile and (with --disable-dev-shm-usage) its
|
|
134
|
+
// shared memory under /tmp — a full disk breaks launches
|
|
135
|
+
const stat = fs.statfsSync('/tmp');
|
|
136
|
+
parts.push(`/tmp free ${gib(stat.bavail * stat.bsize)}`);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// statfs unavailable
|
|
140
|
+
}
|
|
141
|
+
return parts.join(', ');
|
|
142
|
+
}
|
|
51
143
|
class ContentProcessor {
|
|
52
144
|
constructor(logger) {
|
|
53
145
|
this.logger = logger;
|
|
@@ -329,16 +421,8 @@ class ContentProcessor {
|
|
|
329
421
|
let browser = null;
|
|
330
422
|
let page = null;
|
|
331
423
|
const launchBrowser = async () => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (fs.existsSync('/usr/bin/chromium')) {
|
|
335
|
-
executablePath = '/usr/bin/chromium';
|
|
336
|
-
}
|
|
337
|
-
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
338
|
-
executablePath = '/usr/bin/chromium-browser';
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
const b = await puppeteer_1.default.launch({
|
|
424
|
+
const executablePath = resolveBrowserExecutablePath();
|
|
425
|
+
const launchOptions = {
|
|
342
426
|
executablePath,
|
|
343
427
|
args: [
|
|
344
428
|
'--no-sandbox',
|
|
@@ -348,7 +432,25 @@ class ContentProcessor {
|
|
|
348
432
|
'--disable-extensions',
|
|
349
433
|
],
|
|
350
434
|
protocolTimeout: 60000,
|
|
351
|
-
}
|
|
435
|
+
};
|
|
436
|
+
let b;
|
|
437
|
+
try {
|
|
438
|
+
b = await puppeteer_1.default.launch(launchOptions);
|
|
439
|
+
}
|
|
440
|
+
catch (firstError) {
|
|
441
|
+
// One retry after a pause — the failure may be transient (e.g.
|
|
442
|
+
// memory pressure from a page that just closed). If the browser
|
|
443
|
+
// cannot launch at all, no page in this crawl can be processed,
|
|
444
|
+
// so escalate as fatal instead of failing every URL one by one.
|
|
445
|
+
logger.warn(`Browser launch failed (${memoryDiagnostics()}), retrying once in 5s...`, firstError);
|
|
446
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
447
|
+
try {
|
|
448
|
+
b = await puppeteer_1.default.launch(launchOptions);
|
|
449
|
+
}
|
|
450
|
+
catch (secondError) {
|
|
451
|
+
throw new BrowserLaunchError(`browser failed to launch twice (${memoryDiagnostics()}): ${secondError instanceof Error ? secondError.message : String(secondError)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
352
454
|
const p = await b.newPage();
|
|
353
455
|
return { browser: b, page: p };
|
|
354
456
|
};
|
|
@@ -682,6 +784,13 @@ class ContentProcessor {
|
|
|
682
784
|
}
|
|
683
785
|
}
|
|
684
786
|
catch (error) {
|
|
787
|
+
// A browser that cannot launch is fatal for the whole crawl —
|
|
788
|
+
// rethrow so the source (and the run) is reported as failed
|
|
789
|
+
// rather than logging an error per URL and finishing "green".
|
|
790
|
+
if (error instanceof BrowserLaunchError) {
|
|
791
|
+
logger.error(`Aborting crawl of ${baseUrl}: ${error.message}`);
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
685
794
|
const status = this.getHttpStatus(error);
|
|
686
795
|
// ── Handle 429 (Too Many Requests) with retry ──
|
|
687
796
|
if (status === 429) {
|
|
@@ -846,17 +955,8 @@ class ContentProcessor {
|
|
|
846
955
|
}
|
|
847
956
|
else {
|
|
848
957
|
// Standalone mode: launch a browser for this single page
|
|
849
|
-
let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
850
|
-
if (!executablePath) {
|
|
851
|
-
if (fs.existsSync('/usr/bin/chromium')) {
|
|
852
|
-
executablePath = '/usr/bin/chromium';
|
|
853
|
-
}
|
|
854
|
-
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
855
|
-
executablePath = '/usr/bin/chromium-browser';
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
958
|
browser = await puppeteer_1.default.launch({
|
|
859
|
-
executablePath,
|
|
959
|
+
executablePath: resolveBrowserExecutablePath(),
|
|
860
960
|
args: [
|
|
861
961
|
'--no-sandbox',
|
|
862
962
|
'--disable-setuid-sandbox',
|
package/dist/doc2vec.js
CHANGED
|
@@ -176,36 +176,59 @@ class Doc2Vec {
|
|
|
176
176
|
await this.markdownStore.init();
|
|
177
177
|
}
|
|
178
178
|
this.logger.section('PROCESSING SOURCES');
|
|
179
|
+
const runStats = [];
|
|
179
180
|
for (const sourceConfig of this.config.sources) {
|
|
180
181
|
const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
|
|
181
182
|
sourceLogger.info(`Processing ${sourceConfig.type} source for ${sourceConfig.product_name}@${sourceConfig.version}`);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
183
|
+
const startTime = Date.now();
|
|
184
|
+
let ok = true;
|
|
185
|
+
let errorMessage;
|
|
186
|
+
try {
|
|
187
|
+
if (sourceConfig.type === 'github') {
|
|
188
|
+
await this.processGithubRepo(sourceConfig, sourceLogger);
|
|
189
|
+
}
|
|
190
|
+
else if (sourceConfig.type === 'website') {
|
|
191
|
+
await this.processWebsite(sourceConfig, sourceLogger);
|
|
192
|
+
}
|
|
193
|
+
else if (sourceConfig.type === 'local_directory') {
|
|
194
|
+
await this.processLocalDirectory(sourceConfig, sourceLogger);
|
|
195
|
+
}
|
|
196
|
+
else if (sourceConfig.type === 'code') {
|
|
197
|
+
await this.processCodeSource(sourceConfig, sourceLogger);
|
|
198
|
+
}
|
|
199
|
+
else if (sourceConfig.type === 'zendesk') {
|
|
200
|
+
await this.processZendesk(sourceConfig, sourceLogger);
|
|
201
|
+
}
|
|
202
|
+
else if (sourceConfig.type === 's3') {
|
|
203
|
+
await this.processS3(sourceConfig, sourceLogger);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
ok = false;
|
|
207
|
+
errorMessage = `Unknown source type: ${sourceConfig.type}`;
|
|
208
|
+
sourceLogger.error(errorMessage);
|
|
209
|
+
}
|
|
202
210
|
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
ok = false;
|
|
213
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
+
sourceLogger.error(`Failed to process ${sourceConfig.type} source for ${sourceConfig.product_name}:`, error);
|
|
215
|
+
}
|
|
216
|
+
runStats.push({
|
|
217
|
+
product_name: sourceConfig.product_name,
|
|
218
|
+
type: sourceConfig.type,
|
|
219
|
+
version: sourceConfig.version,
|
|
220
|
+
duration_ms: Date.now() - startTime,
|
|
221
|
+
ok,
|
|
222
|
+
...(errorMessage && { error: errorMessage })
|
|
223
|
+
});
|
|
203
224
|
}
|
|
204
225
|
// Close the Postgres markdown store connection pool
|
|
205
226
|
if (this.markdownStore) {
|
|
206
227
|
await this.markdownStore.close();
|
|
207
228
|
}
|
|
208
229
|
this.logger.section('PROCESSING COMPLETE');
|
|
230
|
+
this.logger.event('run-summary', { sources: runStats });
|
|
231
|
+
return runStats;
|
|
209
232
|
}
|
|
210
233
|
async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
|
|
211
234
|
const [owner, repoName] = repo.split('/');
|
|
@@ -608,7 +631,11 @@ class Doc2Vec {
|
|
|
608
631
|
this.brokenLinksByWebsite[baseUrl] = unique;
|
|
609
632
|
}
|
|
610
633
|
writeBrokenLinksReport() {
|
|
611
|
-
|
|
634
|
+
// The default (next to the config file) breaks when the config lives on
|
|
635
|
+
// a read-only mount (e.g. a Kubernetes ConfigMap in controller mode) —
|
|
636
|
+
// DOC2VEC_REPORT_DIR points the report somewhere writable instead.
|
|
637
|
+
const reportDir = process.env.DOC2VEC_REPORT_DIR || this.configDir;
|
|
638
|
+
const reportPath = path.join(reportDir, '404.yaml');
|
|
612
639
|
const orderedEntries = Object.entries(this.brokenLinksByWebsite)
|
|
613
640
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
614
641
|
const reportPayload = orderedEntries.map(([website, links]) => ({
|
|
@@ -1651,14 +1678,64 @@ exports.Doc2Vec = Doc2Vec;
|
|
|
1651
1678
|
Doc2Vec.MAX_EMBEDDING_TOKENS = 8191;
|
|
1652
1679
|
Doc2Vec.CHARS_PER_TOKEN = 4;
|
|
1653
1680
|
Doc2Vec.MAX_EMBEDDING_CHARS = Doc2Vec.MAX_EMBEDDING_TOKENS * Doc2Vec.CHARS_PER_TOKEN;
|
|
1654
|
-
|
|
1655
|
-
const configPath = process.argv[2] || 'config.yaml';
|
|
1681
|
+
function runOneShot(configPath) {
|
|
1656
1682
|
if (!fs.existsSync(configPath)) {
|
|
1657
1683
|
console.error('Please provide a valid path to a YAML config file.');
|
|
1658
1684
|
process.exit(1);
|
|
1659
1685
|
}
|
|
1660
1686
|
const doc2Vec = new Doc2Vec(configPath);
|
|
1661
1687
|
doc2Vec.run()
|
|
1662
|
-
.then(() => process.exit(0))
|
|
1688
|
+
.then((stats) => process.exit(stats.some(s => !s.ok) ? 1 : 0))
|
|
1663
1689
|
.catch((err) => { console.error(err); process.exit(1); });
|
|
1664
1690
|
}
|
|
1691
|
+
if (require.main === module) {
|
|
1692
|
+
const { Command } = require('commander');
|
|
1693
|
+
const program = new Command();
|
|
1694
|
+
program
|
|
1695
|
+
.name('doc2vec')
|
|
1696
|
+
.description('Crawl documentation sources and store embeddings in vector databases')
|
|
1697
|
+
// Legacy invocation: `doc2vec [config.yaml]` runs a one-shot sync
|
|
1698
|
+
.argument('[config]', 'path to a YAML config file', 'config.yaml')
|
|
1699
|
+
.action((configPath) => runOneShot(configPath));
|
|
1700
|
+
program
|
|
1701
|
+
.command('run <config>')
|
|
1702
|
+
.description('Run a one-shot sync for a config file (what the controller spawns)')
|
|
1703
|
+
.action((configPath) => runOneShot(configPath));
|
|
1704
|
+
program
|
|
1705
|
+
.command('controller [configs...]')
|
|
1706
|
+
.description('Run as a long-lived controller: schedule sync jobs per config file, persist runs in Postgres, serve a web UI')
|
|
1707
|
+
.option('--database-url <url>', 'Postgres connection string (or DATABASE_URL env var)')
|
|
1708
|
+
.option('--port <port>', 'HTTP port for the API/UI (or PORT env var)', (v) => parseInt(v, 10))
|
|
1709
|
+
.option('--read-write', 'allow creating/editing configs from the UI (default: read-only)', false)
|
|
1710
|
+
.option('--config-dir <dir>', 'directory where UI-created configs are written (required with --read-write)')
|
|
1711
|
+
.option('--max-parallel <n>', 'maximum number of sync jobs running at once', (v) => parseInt(v, 10), 1)
|
|
1712
|
+
.option('--reload-interval <seconds>', 'how often to re-poll config files for changes', (v) => parseInt(v, 10), 30)
|
|
1713
|
+
.option('--log-retention-days <days>', 'delete run logs older than this many days', (v) => parseInt(v, 10), 14)
|
|
1714
|
+
.option('--slack-webhook-url <url>', 'Slack incoming webhook for run notifications (or SLACK_WEBHOOK_URL env var)')
|
|
1715
|
+
.option('--slack-notify <mode>', "which runs to notify about: 'all' or 'failures'", 'all')
|
|
1716
|
+
.option('--public-url <url>', 'externally reachable base URL, used for links in notifications (or PUBLIC_URL env var)')
|
|
1717
|
+
.action(async (configs, options) => {
|
|
1718
|
+
// Lazy-require so the one-shot path doesn't load express/pg
|
|
1719
|
+
const { startController } = require('./controller');
|
|
1720
|
+
try {
|
|
1721
|
+
await startController({
|
|
1722
|
+
configArgs: configs,
|
|
1723
|
+
databaseUrl: options.databaseUrl || process.env.DATABASE_URL,
|
|
1724
|
+
port: options.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 8080),
|
|
1725
|
+
readWrite: options.readWrite,
|
|
1726
|
+
configDir: options.configDir,
|
|
1727
|
+
maxParallel: options.maxParallel,
|
|
1728
|
+
reloadIntervalSec: options.reloadInterval,
|
|
1729
|
+
logRetentionDays: options.logRetentionDays,
|
|
1730
|
+
slackWebhookUrl: options.slackWebhookUrl || process.env.SLACK_WEBHOOK_URL,
|
|
1731
|
+
slackNotify: options.slackNotify === 'failures' ? 'failures' : 'all',
|
|
1732
|
+
publicUrl: options.publicUrl || process.env.PUBLIC_URL,
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1737
|
+
process.exit(1);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
program.parse();
|
|
1741
|
+
}
|
package/dist/logger.js
CHANGED
|
@@ -16,6 +16,35 @@ var LogLevel;
|
|
|
16
16
|
LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
|
|
17
17
|
LogLevel[LogLevel["NONE"] = 100] = "NONE";
|
|
18
18
|
})(LogLevel || (exports.LogLevel = LogLevel = {}));
|
|
19
|
+
/**
|
|
20
|
+
* When DOC2VEC_STRUCTURED_LOGS=1 (set by the controller when spawning sync jobs),
|
|
21
|
+
* every log line is emitted as a single JSON object so a parent process can parse
|
|
22
|
+
* the stream reliably. Human-formatted output is suppressed in this mode.
|
|
23
|
+
*/
|
|
24
|
+
const STRUCTURED = process.env.DOC2VEC_STRUCTURED_LOGS === '1';
|
|
25
|
+
function emitStructured(payload, useStderr = false) {
|
|
26
|
+
const line = JSON.stringify(payload);
|
|
27
|
+
if (useStderr) {
|
|
28
|
+
process.stderr.write(line + '\n');
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
process.stdout.write(line + '\n');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function stringifyArg(arg) {
|
|
35
|
+
if (arg instanceof Error) {
|
|
36
|
+
return `${arg.message}${arg.stack ? `\n${arg.stack}` : ''}`;
|
|
37
|
+
}
|
|
38
|
+
if (typeof arg === 'object' && arg !== null) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(arg);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return '[Unserializable Object]';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return String(arg);
|
|
47
|
+
}
|
|
19
48
|
/**
|
|
20
49
|
* Basic color functions that don't rely on external packages
|
|
21
50
|
*/
|
|
@@ -117,8 +146,23 @@ class Logger {
|
|
|
117
146
|
* @param message Message to log
|
|
118
147
|
* @param args Additional arguments
|
|
119
148
|
*/
|
|
149
|
+
emitJson(level, message, args, useStderr = false) {
|
|
150
|
+
const msg = args.length > 0
|
|
151
|
+
? `${message} ${args.map(stringifyArg).join(' ')}`
|
|
152
|
+
: message;
|
|
153
|
+
emitStructured({
|
|
154
|
+
ts: new Date().toISOString(),
|
|
155
|
+
level,
|
|
156
|
+
module: this.moduleName,
|
|
157
|
+
msg
|
|
158
|
+
}, useStderr);
|
|
159
|
+
}
|
|
120
160
|
debug(message, ...args) {
|
|
121
161
|
if (this.config.level <= LogLevel.DEBUG) {
|
|
162
|
+
if (STRUCTURED) {
|
|
163
|
+
this.emitJson('debug', message, args);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
122
166
|
const formattedMessage = this.formatMessage('DEBUG', message, args);
|
|
123
167
|
console.log(this.colorize(LogLevel.DEBUG, formattedMessage));
|
|
124
168
|
}
|
|
@@ -131,6 +175,10 @@ class Logger {
|
|
|
131
175
|
*/
|
|
132
176
|
info(message, ...args) {
|
|
133
177
|
if (this.config.level <= LogLevel.INFO) {
|
|
178
|
+
if (STRUCTURED) {
|
|
179
|
+
this.emitJson('info', message, args);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
134
182
|
const formattedMessage = this.formatMessage('INFO', message, args);
|
|
135
183
|
console.log(this.colorize(LogLevel.INFO, formattedMessage));
|
|
136
184
|
}
|
|
@@ -143,6 +191,10 @@ class Logger {
|
|
|
143
191
|
*/
|
|
144
192
|
warn(message, ...args) {
|
|
145
193
|
if (this.config.level <= LogLevel.WARN) {
|
|
194
|
+
if (STRUCTURED) {
|
|
195
|
+
this.emitJson('warn', message, args, true);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
146
198
|
const formattedMessage = this.formatMessage('WARN', message, args);
|
|
147
199
|
console.warn(this.colorize(LogLevel.WARN, formattedMessage));
|
|
148
200
|
}
|
|
@@ -155,10 +207,28 @@ class Logger {
|
|
|
155
207
|
*/
|
|
156
208
|
error(message, ...args) {
|
|
157
209
|
if (this.config.level <= LogLevel.ERROR) {
|
|
210
|
+
if (STRUCTURED) {
|
|
211
|
+
this.emitJson('error', message, args, true);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
158
214
|
const formattedMessage = this.formatMessage('ERROR', message, args);
|
|
159
215
|
console.error(this.colorize(LogLevel.ERROR, formattedMessage));
|
|
160
216
|
}
|
|
161
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Emit a structured event (e.g. run-summary). In structured mode this prints a
|
|
220
|
+
* machine-parseable JSON line; otherwise it logs the payload at info level.
|
|
221
|
+
*
|
|
222
|
+
* @param name Event name
|
|
223
|
+
* @param data Event payload
|
|
224
|
+
*/
|
|
225
|
+
event(name, data = {}) {
|
|
226
|
+
if (STRUCTURED) {
|
|
227
|
+
emitStructured({ event: name, ts: new Date().toISOString(), ...data });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.info(`[event:${name}]`, data);
|
|
231
|
+
}
|
|
162
232
|
/**
|
|
163
233
|
* Create a child logger with a more specific module name
|
|
164
234
|
*
|
|
@@ -176,6 +246,10 @@ class Logger {
|
|
|
176
246
|
*/
|
|
177
247
|
section(title) {
|
|
178
248
|
if (this.config.level <= LogLevel.INFO) {
|
|
249
|
+
if (STRUCTURED) {
|
|
250
|
+
emitStructured({ event: 'section', ts: new Date().toISOString(), module: this.moduleName, title });
|
|
251
|
+
return this;
|
|
252
|
+
}
|
|
179
253
|
const separator = '='.repeat(Math.max(80 - title.length - 4, 10));
|
|
180
254
|
const message = `${separator} ${title} ${separator}`;
|
|
181
255
|
console.log(this.colorize(LogLevel.INFO, message));
|
|
@@ -192,10 +266,21 @@ class Logger {
|
|
|
192
266
|
progress(title, total) {
|
|
193
267
|
let current = 0;
|
|
194
268
|
const startTime = Date.now();
|
|
269
|
+
// In structured mode, throttle progress lines to at most one per second so a
|
|
270
|
+
// large crawl doesn't flood the parent process / database with log rows.
|
|
271
|
+
let lastStructuredEmit = 0;
|
|
195
272
|
const update = (increment = 1, message) => {
|
|
196
273
|
if (this.config.level > LogLevel.INFO)
|
|
197
274
|
return;
|
|
198
275
|
current += increment;
|
|
276
|
+
if (STRUCTURED) {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
if (now - lastStructuredEmit >= 1000 || current === total) {
|
|
279
|
+
lastStructuredEmit = now;
|
|
280
|
+
emitStructured({ event: 'progress', ts: new Date().toISOString(), module: this.moduleName, title, current, total, message });
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
199
284
|
const percentage = Math.min(Math.floor((current / total) * 100), 100);
|
|
200
285
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
201
286
|
let rate = current / elapsed;
|
|
@@ -213,6 +298,10 @@ class Logger {
|
|
|
213
298
|
return;
|
|
214
299
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
215
300
|
const rate = total / elapsed;
|
|
301
|
+
if (STRUCTURED) {
|
|
302
|
+
emitStructured({ event: 'progress', ts: new Date().toISOString(), module: this.moduleName, title, current: total, total, message: `${message} in ${elapsed.toFixed(2)}s`, done: true });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
216
305
|
console.log(this.colorize(LogLevel.INFO, this.formatMessage('INFO', `${title}: ${this.createProgressBar(100)} 100% (${total}/${total}) - ${message} in ${elapsed.toFixed(2)}s (${rate.toFixed(2)} items/sec)`)));
|
|
217
306
|
};
|
|
218
307
|
return { update, complete };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc2vec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "",
|
|
6
6
|
"main": "dist/doc2vec.js",
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc && chmod 755 dist/doc2vec.js",
|
|
21
|
+
"build:ui": "npm --prefix ui ci && npm --prefix ui run build",
|
|
21
22
|
"start": "node dist/doc2vec.js",
|
|
22
23
|
"test": "vitest run",
|
|
23
24
|
"test:watch": "vitest",
|
|
24
25
|
"test:coverage": "vitest run --coverage",
|
|
25
|
-
"prepublishOnly": "npm run build",
|
|
26
|
+
"prepublishOnly": "npm run build && npm run build:ui",
|
|
26
27
|
"prepare": "npm run build"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [],
|
|
@@ -38,7 +39,10 @@
|
|
|
38
39
|
"better-sqlite3": "^11.9.1",
|
|
39
40
|
"chalk": "^5.4.1",
|
|
40
41
|
"cheerio": "^1.0.0-rc.12",
|
|
42
|
+
"commander": "^15.0.0",
|
|
43
|
+
"croner": "^10.0.1",
|
|
41
44
|
"dotenv": "^16.3.1",
|
|
45
|
+
"express": "^5.2.1",
|
|
42
46
|
"js-yaml": "^4.1.0",
|
|
43
47
|
"jsdom": "^26.0.0",
|
|
44
48
|
"mammoth": "^1.11.0",
|
|
@@ -55,6 +59,7 @@
|
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|
|
57
61
|
"@types/better-sqlite3": "^7.6.12",
|
|
62
|
+
"@types/express": "^5.0.6",
|
|
58
63
|
"@types/js-yaml": "^4.0.9",
|
|
59
64
|
"@types/jsdom": "^21.1.7",
|
|
60
65
|
"@types/node": "^20.10.0",
|