doc2vec 2.11.0 → 2.13.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 +57 -4
- package/dist/content-processor.js +12 -3
- package/dist/controller/chunk-inspector.js +209 -0
- package/dist/controller/migrations.js +5 -0
- package/dist/controller/server.js +79 -3
- package/dist/controller/store.js +39 -2
- package/dist/database.js +64 -19
- package/dist/doc2vec.js +180 -38
- package/dist/types.js +12 -0
- package/dist/ui/assets/index-C-uNubcD.css +1 -0
- package/dist/ui/assets/index-zutKK2b4.js +126 -0
- package/dist/ui/index.html +2 -2
- package/dist/utils.js +58 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-BVg6n7Si.css +0 -1
- package/dist/ui/assets/index-D-QOw3rk.js +0 -126
package/README.md
CHANGED
|
@@ -6,7 +6,12 @@ This project provides a configurable tool (`doc2vec`) to crawl specified website
|
|
|
6
6
|
|
|
7
7
|
The primary goal is to prepare documentation content for Retrieval-Augmented Generation (RAG) systems or semantic search applications.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Run it two ways:
|
|
10
|
+
|
|
11
|
+
* **One-shot sync** — `doc2vec run config.yaml` processes every source in a config file and exits. Ideal for a cron job or CI step.
|
|
12
|
+
* **[Controller mode](#controller-mode)** — `doc2vec controller ./configs/` stays running: it schedules each config on its own cron expression, keeps run history and per-source statistics in Postgres, and serves a web UI with live log streaming, searchable run logs, and chunk inspection. Deploy it once and manage all your sources from the browser.
|
|
13
|
+
|
|
14
|
+
[](#controller-mode)
|
|
10
15
|
|
|
11
16
|
## Key Features
|
|
12
17
|
|
|
@@ -52,7 +57,7 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
|
|
|
52
57
|
* **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
|
|
53
58
|
* **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
|
|
54
59
|
* **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).
|
|
60
|
+
* **[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, whole-run log search, and chunk inspection — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
|
|
56
61
|
* **Structured Logging:** Uses a custom logger (`logger.ts`) with levels, timestamps, colors, progress bars, and child loggers for clear execution monitoring.
|
|
57
62
|
|
|
58
63
|
## Chunk Metadata & Page Reconstruction
|
|
@@ -446,7 +451,19 @@ Besides the one-shot sync, doc2vec can run as a **long-lived controller** that s
|
|
|
446
451
|
doc2vec controller --database-url postgres://user:pass@host:5432/doc2vec ./configs/
|
|
447
452
|
```
|
|
448
453
|
|
|
449
|
-
Open http://localhost:8080 to see the dashboard:
|
|
454
|
+
Open http://localhost:8080 to see the dashboard ([screenshot above](#doc2vec)): every config with its schedule, next run, and the outcome of its last run, plus a **Run now** button.
|
|
455
|
+
|
|
456
|
+
Each config gets its own page with the full run history, per-source results, and the raw YAML:
|
|
457
|
+
|
|
458
|
+

|
|
459
|
+
|
|
460
|
+
…and a **Stats** tab with runs per day, success rate, and duration trend over the last 7/30/90 days:
|
|
461
|
+
|
|
462
|
+

|
|
463
|
+
|
|
464
|
+
A run page shows what the sync did — duration, exit code, warning and error counts, per-source chunk counts and errors — above the run's logs.
|
|
465
|
+
|
|
466
|
+

|
|
450
467
|
|
|
451
468
|
### Scheduling
|
|
452
469
|
|
|
@@ -462,6 +479,20 @@ sources:
|
|
|
462
479
|
|
|
463
480
|
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
481
|
|
|
482
|
+
### Logs
|
|
483
|
+
|
|
484
|
+
Every line a sync job writes is stored in Postgres and streamed to the run page as it happens (`● live`), with follow-on-scroll and a `↓ Follow` button when you scroll away.
|
|
485
|
+
|
|
486
|
+
Long runs produce a lot of output, so the browser only keeps the last 10,000 lines in memory — the header tells you when that is the case (`30,000 lines (showing last 10,000)`). Everything else stays one query away:
|
|
487
|
+
|
|
488
|
+
* **Level chips** (`ERROR 4`, `WARN 93`, …) count the whole run, not the visible window, and clicking one filters **server-side** — so the 4 errors from the first minute of a 50-minute run are one click away. Results page in with **Load 2,000 more**.
|
|
489
|
+
* **Keyword filter** matches the message or the module, case-insensitively, across the whole run.
|
|
490
|
+
* **↓ download log** streams the complete log as plain text (`run-<id>.log`) for grepping locally or attaching to an issue. With a filter active it becomes **↓ download matches** and applies the same filter.
|
|
491
|
+
|
|
492
|
+
While a run is live, new lines matching the active filter keep arriving in the filtered view.
|
|
493
|
+
|
|
494
|
+
Run logs are pruned after `--log-retention-days` (default 14); run history and statistics are kept indefinitely.
|
|
495
|
+
|
|
465
496
|
### Flags
|
|
466
497
|
|
|
467
498
|
| Flag | Default | Description |
|
|
@@ -533,7 +564,22 @@ The controller detects ConfigMap updates without a restart. Health endpoint: `GE
|
|
|
533
564
|
|
|
534
565
|
### API
|
|
535
566
|
|
|
536
|
-
The UI is backed by a small REST API you can also use directly:
|
|
567
|
+
The UI is backed by a small REST API you can also use directly:
|
|
568
|
+
|
|
569
|
+
| Endpoint | Description |
|
|
570
|
+
|----------|-------------|
|
|
571
|
+
| `GET /api/configs` | Configs with schedule, next run, and last run |
|
|
572
|
+
| `POST /api/configs/:id/run` | Trigger a run now |
|
|
573
|
+
| `GET /api/configs/:id/stats?days=30` | Run counts, success rate, duration history |
|
|
574
|
+
| `GET /api/configs/:id/chunks?product_name=&url=` | Inspect the stored chunks for a URL |
|
|
575
|
+
| `GET /api/runs?configId=&status=&limit=&before=` | Run history |
|
|
576
|
+
| `GET /api/runs/:id/logs?afterSeq=&limit=&levels=&q=` | Log lines, filtered by level (csv) and/or keyword |
|
|
577
|
+
| `GET /api/runs/:id/logs/counts` | Line totals per level for the whole run |
|
|
578
|
+
| `GET /api/runs/:id/logs/download?levels=&q=` | Whole log (or the matching lines) as plain text |
|
|
579
|
+
| `POST /api/runs/:id/cancel` | Cancel a running job |
|
|
580
|
+
| `GET /api/health` | Status, mode, version |
|
|
581
|
+
|
|
582
|
+
Server-sent events: `GET /api/events` (run and config updates) and `GET /api/runs/:id/logs/stream?tail=10000` (replays the last `tail` stored lines, then tails live output).
|
|
537
583
|
|
|
538
584
|
## Database Options
|
|
539
585
|
|
|
@@ -721,6 +767,13 @@ If you don't specify a config path, it will look for config.yaml in the current
|
|
|
721
767
|
|
|
722
768
|
## Recent Changes
|
|
723
769
|
|
|
770
|
+
### Searchable Controller Run Logs
|
|
771
|
+
- **Server-side log filtering:** Level chips and the keyword filter now query Postgres instead of the browser's in-memory buffer, so errors and warnings from the start of a long run are reachable (`GET /api/runs/:id/logs?levels=error,warn&q=...`, paged with **Load 2,000 more**)
|
|
772
|
+
- **Whole-run level counts:** New `GET /api/runs/:id/logs/counts` endpoint backs the level chips, which previously only counted the lines still held in the browser
|
|
773
|
+
- **Plain-text download:** `GET /api/runs/:id/logs/download` streams the full log (or just the matching lines) as `run-<id>.log`
|
|
774
|
+
- **Bounded replay:** The log stream accepts `?tail=N` so opening a 30,000-line run replays only the trailing window instead of the entire history
|
|
775
|
+
- **Index:** New `d2v_run_logs (run_id, level, seq)` index keeps level filtering fast on long runs
|
|
776
|
+
|
|
724
777
|
### Postgres Markdown Store
|
|
725
778
|
- **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
|
|
726
779
|
- **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
|
|
@@ -1841,7 +1841,9 @@ class ContentProcessor {
|
|
|
1841
1841
|
if (!content) {
|
|
1842
1842
|
continue;
|
|
1843
1843
|
}
|
|
1844
|
-
|
|
1844
|
+
// See chunkMarkdown: an unpaired surrogate makes the store's JSON
|
|
1845
|
+
// body invalid, so sanitize before the id is derived from the text.
|
|
1846
|
+
const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content);
|
|
1845
1847
|
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1846
1848
|
documentChunks.push({
|
|
1847
1849
|
content: searchableText,
|
|
@@ -1913,7 +1915,10 @@ class ContentProcessor {
|
|
|
1913
1915
|
// to searches for parent topics even if the body doesn't mention them.
|
|
1914
1916
|
const breadcrumbs = hierarchy.filter(h => h).join(" > ");
|
|
1915
1917
|
const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
|
|
1916
|
-
|
|
1918
|
+
// Sanitize before hashing so the id and the stored text always agree:
|
|
1919
|
+
// an unpaired surrogate (from a split pair, or straight from the
|
|
1920
|
+
// source) makes the JSON body invalid and the write fails.
|
|
1921
|
+
const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content.trim());
|
|
1917
1922
|
const chunkId = utils_1.Utils.generateHash(searchableText);
|
|
1918
1923
|
const chunk = {
|
|
1919
1924
|
content: searchableText,
|
|
@@ -1954,7 +1959,11 @@ class ContentProcessor {
|
|
|
1954
1959
|
// If the section is a massive guide, split it but keep headers on every sub-piece.
|
|
1955
1960
|
const overlapSize = Math.floor(MAX_CHARS * OVERLAP_PERCENT);
|
|
1956
1961
|
for (let i = 0; i < trimmedBuffer.length; i += (MAX_CHARS - overlapSize)) {
|
|
1957
|
-
|
|
1962
|
+
// Slice on code points, not code units: a boundary falling
|
|
1963
|
+
// inside a surrogate pair (emoji, some CJK) would leave a
|
|
1964
|
+
// lone surrogate, which Qdrant's JSON parser rejects — the
|
|
1965
|
+
// chunk then fails to store and is silently lost.
|
|
1966
|
+
const subContent = utils_1.Utils.sliceSafe(trimmedBuffer, i, i + MAX_CHARS);
|
|
1958
1967
|
chunks.push(createDocumentChunk(subContent, topicHierarchy));
|
|
1959
1968
|
}
|
|
1960
1969
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.lookupChunks = lookupChunks;
|
|
40
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const sqliteVec = __importStar(require("sqlite-vec"));
|
|
43
|
+
const yaml = __importStar(require("js-yaml"));
|
|
44
|
+
const js_client_rest_1 = require("@qdrant/js-client-rest");
|
|
45
|
+
const types_1 = require("./types");
|
|
46
|
+
const MAX_CHUNKS = 500;
|
|
47
|
+
/**
|
|
48
|
+
* Read-only lookup of the chunks stored for a URL in a source's vector store.
|
|
49
|
+
* Resolves the store the same way a sync job would (same defaults as
|
|
50
|
+
* DatabaseManager.initDatabase, same ${ENV_VAR} substitution as Doc2Vec's
|
|
51
|
+
* config loader), but never creates tables or collections.
|
|
52
|
+
*/
|
|
53
|
+
async function lookupChunks(configContent, selector, url) {
|
|
54
|
+
const source = findSource(configContent, selector);
|
|
55
|
+
const dbConfig = source.database_config ?? {};
|
|
56
|
+
const params = dbConfig.params ?? {};
|
|
57
|
+
if (dbConfig.type === 'sqlite') {
|
|
58
|
+
const dbPath = params.db_path
|
|
59
|
+
|| path.join(process.cwd(), `${String(source.product_name).replace(/\s+/g, '_')}-${source.version}.db`);
|
|
60
|
+
return {
|
|
61
|
+
source: { product_name: source.product_name, type: source.type, version: source.version },
|
|
62
|
+
database: { type: 'sqlite', path: dbPath },
|
|
63
|
+
chunks: lookupSqlite(dbPath, url),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (dbConfig.type === 'qdrant') {
|
|
67
|
+
const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
|
|
68
|
+
const qdrantPort = params.qdrant_port || 443;
|
|
69
|
+
const collectionName = params.collection_name
|
|
70
|
+
|| `${String(source.product_name).toLowerCase().replace(/\s+/g, '_')}_${source.version}`;
|
|
71
|
+
const client = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
|
|
72
|
+
return {
|
|
73
|
+
source: { product_name: source.product_name, type: source.type, version: source.version },
|
|
74
|
+
database: { type: 'qdrant', url: qdrantUrl, collection: collectionName },
|
|
75
|
+
chunks: await lookupQdrant(client, collectionName, url),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
throw new types_1.ValidationError(`source has unsupported database type: ${dbConfig.type}`);
|
|
79
|
+
}
|
|
80
|
+
function findSource(configContent, selector) {
|
|
81
|
+
// Same ${VAR} substitution the sync job applies when it loads the config
|
|
82
|
+
const substituted = configContent.replace(/\$\{([^}]+)\}/g, (match, varName) => process.env[varName] !== undefined ? process.env[varName] : match);
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = yaml.load(substituted);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new types_1.ValidationError(`config does not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
}
|
|
90
|
+
const sources = Array.isArray(parsed?.sources) ? parsed.sources : [];
|
|
91
|
+
const source = sources.find(s => s?.product_name === selector.product_name
|
|
92
|
+
&& (selector.type === undefined || s?.type === selector.type)
|
|
93
|
+
&& (selector.version === undefined || String(effectiveVersion(s)) === selector.version));
|
|
94
|
+
if (!source) {
|
|
95
|
+
throw new types_1.NotFoundError(`source '${selector.product_name}' not found in config`);
|
|
96
|
+
}
|
|
97
|
+
return { ...source, version: effectiveVersion(source) };
|
|
98
|
+
}
|
|
99
|
+
// Mirrors the version defaulting in Doc2Vec.loadConfig for code sources
|
|
100
|
+
function effectiveVersion(source) {
|
|
101
|
+
if (source?.version && String(source.version).trim().length > 0)
|
|
102
|
+
return String(source.version);
|
|
103
|
+
if (source?.type === 'code') {
|
|
104
|
+
if (source.branch && String(source.branch).trim().length > 0)
|
|
105
|
+
return String(source.branch);
|
|
106
|
+
return 'local';
|
|
107
|
+
}
|
|
108
|
+
return String(source?.version ?? '');
|
|
109
|
+
}
|
|
110
|
+
function lookupSqlite(dbPath, url) {
|
|
111
|
+
let db;
|
|
112
|
+
try {
|
|
113
|
+
db = new better_sqlite3_1.default(dbPath, { readonly: true, fileMustExist: true, allowExtension: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new types_1.NotFoundError(`SQLite database not found at ${dbPath} (has this source ever been synced?)`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
sqliteVec.load(db);
|
|
120
|
+
const hasDates = db
|
|
121
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vec_chunk_dates'`)
|
|
122
|
+
.get() !== undefined;
|
|
123
|
+
const rows = db.prepare(`
|
|
124
|
+
SELECT v.chunk_id, v.url, v.product_name, v.version, v.section, v.heading_hierarchy,
|
|
125
|
+
v.chunk_index, v.total_chunks, v.hash, v.content
|
|
126
|
+
${hasDates ? ', d.created_at AS created_at' : ', NULL AS created_at'}
|
|
127
|
+
FROM vec_items v
|
|
128
|
+
${hasDates ? 'LEFT JOIN vec_chunk_dates d ON d.chunk_id = v.chunk_id' : ''}
|
|
129
|
+
WHERE v.url = ?
|
|
130
|
+
LIMIT ${MAX_CHUNKS}
|
|
131
|
+
`).all(url);
|
|
132
|
+
return sortChunks(rows.map(row => ({
|
|
133
|
+
chunk_id: String(row.chunk_id),
|
|
134
|
+
url: String(row.url),
|
|
135
|
+
product_name: row.product_name ?? null,
|
|
136
|
+
version: row.version ?? null,
|
|
137
|
+
section: row.section ?? null,
|
|
138
|
+
heading_hierarchy: parseHierarchy(row.heading_hierarchy),
|
|
139
|
+
chunk_index: row.chunk_index === null || row.chunk_index === undefined ? null : Number(row.chunk_index),
|
|
140
|
+
total_chunks: row.total_chunks === null || row.total_chunks === undefined ? null : Number(row.total_chunks),
|
|
141
|
+
hash: row.hash ?? null,
|
|
142
|
+
created_at: row.created_at ?? null,
|
|
143
|
+
content: String(row.content ?? ''),
|
|
144
|
+
})));
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
db.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function lookupQdrant(client, collectionName, url) {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let offset = undefined;
|
|
153
|
+
do {
|
|
154
|
+
const response = await client.scroll(collectionName, {
|
|
155
|
+
limit: 200,
|
|
156
|
+
offset,
|
|
157
|
+
with_payload: true,
|
|
158
|
+
with_vector: false,
|
|
159
|
+
filter: {
|
|
160
|
+
must: [{ key: 'url', match: { value: url } }],
|
|
161
|
+
must_not: [{ key: 'is_metadata', match: { value: true } }],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
for (const point of response.points) {
|
|
165
|
+
const p = point.payload ?? {};
|
|
166
|
+
chunks.push({
|
|
167
|
+
chunk_id: String(p.original_chunk_id ?? point.id),
|
|
168
|
+
url: String(p.url ?? url),
|
|
169
|
+
product_name: p.product_name ?? null,
|
|
170
|
+
version: p.version ?? null,
|
|
171
|
+
section: p.section ?? null,
|
|
172
|
+
heading_hierarchy: parseHierarchy(p.heading_hierarchy),
|
|
173
|
+
chunk_index: p.chunk_index === null || p.chunk_index === undefined ? null : Number(p.chunk_index),
|
|
174
|
+
total_chunks: p.total_chunks === null || p.total_chunks === undefined ? null : Number(p.total_chunks),
|
|
175
|
+
hash: p.hash ?? null,
|
|
176
|
+
created_at: p.created_at ?? null,
|
|
177
|
+
content: String(p.content ?? ''),
|
|
178
|
+
});
|
|
179
|
+
if (chunks.length >= MAX_CHUNKS)
|
|
180
|
+
return sortChunks(chunks);
|
|
181
|
+
}
|
|
182
|
+
offset = response.next_page_offset;
|
|
183
|
+
} while (offset !== null && offset !== undefined);
|
|
184
|
+
return sortChunks(chunks);
|
|
185
|
+
}
|
|
186
|
+
function parseHierarchy(value) {
|
|
187
|
+
if (Array.isArray(value))
|
|
188
|
+
return value.map(String);
|
|
189
|
+
if (typeof value === 'string' && value.trim().startsWith('[')) {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(value);
|
|
192
|
+
if (Array.isArray(parsed))
|
|
193
|
+
return parsed.map(String);
|
|
194
|
+
}
|
|
195
|
+
catch { /* fall through */ }
|
|
196
|
+
}
|
|
197
|
+
return value ? [String(value)] : [];
|
|
198
|
+
}
|
|
199
|
+
// Default order: position within the page. The UI offers per-column sorting
|
|
200
|
+
// (including by creation date) on top of this.
|
|
201
|
+
function sortChunks(chunks) {
|
|
202
|
+
return chunks.sort((a, b) => {
|
|
203
|
+
const ai = a.chunk_index ?? Number.MAX_SAFE_INTEGER;
|
|
204
|
+
const bi = b.chunk_index ?? Number.MAX_SAFE_INTEGER;
|
|
205
|
+
if (ai !== bi)
|
|
206
|
+
return ai - bi;
|
|
207
|
+
return a.chunk_id.localeCompare(b.chunk_id);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -52,4 +52,9 @@ exports.MIGRATIONS = [
|
|
|
52
52
|
PRIMARY KEY (run_id, seq)
|
|
53
53
|
);
|
|
54
54
|
`,
|
|
55
|
+
// 2: index for server-side log filtering by level (error/warn lookups on
|
|
56
|
+
// long runs, where the client only keeps a trailing window in memory)
|
|
57
|
+
`
|
|
58
|
+
CREATE INDEX IF NOT EXISTS d2v_run_logs_level_idx ON d2v_run_logs (run_id, level, seq);
|
|
59
|
+
`,
|
|
55
60
|
];
|
|
@@ -40,9 +40,20 @@ exports.createServer = createServer;
|
|
|
40
40
|
const express_1 = __importDefault(require("express"));
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
|
+
const chunk_inspector_1 = require("./chunk-inspector");
|
|
43
44
|
const config_registry_1 = require("./config-registry");
|
|
44
45
|
const types_1 = require("./types");
|
|
45
46
|
const SSE_HEARTBEAT_MS = 15000;
|
|
47
|
+
const LOG_PAGE_SIZE = 5000;
|
|
48
|
+
const LOG_LEVELS = new Set(['error', 'warn', 'info', 'debug']);
|
|
49
|
+
/** Shared parsing for the `levels` (csv) and `q` log query parameters. */
|
|
50
|
+
function parseLogFilter(req) {
|
|
51
|
+
const levels = typeof req.query.levels === 'string'
|
|
52
|
+
? req.query.levels.split(',').map(l => l.trim().toLowerCase()).filter(l => LOG_LEVELS.has(l))
|
|
53
|
+
: [];
|
|
54
|
+
const keyword = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
|
55
|
+
return { levels, keyword: keyword || undefined };
|
|
56
|
+
}
|
|
46
57
|
function getVersion() {
|
|
47
58
|
try {
|
|
48
59
|
return require('../../package.json').version;
|
|
@@ -151,6 +162,34 @@ function createServer(deps) {
|
|
|
151
162
|
const days = Number(req.query.days) || 30;
|
|
152
163
|
res.json(await store.getConfigStats(id, days));
|
|
153
164
|
});
|
|
165
|
+
// Inspect the chunks currently stored for a URL in one source's vector store.
|
|
166
|
+
// This reads the live store, so results reflect the present state, not a
|
|
167
|
+
// historical run.
|
|
168
|
+
app.get('/api/configs/:id/chunks', async (req, res) => {
|
|
169
|
+
const config = registry.get(parseId(String(req.params.id)));
|
|
170
|
+
if (!config)
|
|
171
|
+
throw new types_1.NotFoundError('config not found');
|
|
172
|
+
const url = typeof req.query.url === 'string' ? req.query.url.trim() : '';
|
|
173
|
+
const productName = typeof req.query.product_name === 'string' ? req.query.product_name : '';
|
|
174
|
+
if (!url)
|
|
175
|
+
throw new types_1.ValidationError('url query parameter is required');
|
|
176
|
+
if (!productName)
|
|
177
|
+
throw new types_1.ValidationError('product_name query parameter is required');
|
|
178
|
+
try {
|
|
179
|
+
res.json(await (0, chunk_inspector_1.lookupChunks)(config.content, {
|
|
180
|
+
product_name: productName,
|
|
181
|
+
type: typeof req.query.type === 'string' && req.query.type ? req.query.type : undefined,
|
|
182
|
+
version: typeof req.query.version === 'string' && req.query.version ? req.query.version : undefined,
|
|
183
|
+
}, url));
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err instanceof types_1.NotFoundError || err instanceof types_1.ValidationError)
|
|
187
|
+
throw err;
|
|
188
|
+
// Vector store unreachable (e.g. Qdrant down) — surface the reason
|
|
189
|
+
logger.error('Chunk lookup failed:', err);
|
|
190
|
+
res.status(502).json({ error: `chunk lookup failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
154
193
|
// ------------------------------------------------------------------ runs
|
|
155
194
|
app.get('/api/runs', async (req, res) => {
|
|
156
195
|
res.json(await store.listRuns({
|
|
@@ -173,7 +212,39 @@ function createServer(deps) {
|
|
|
173
212
|
throw new types_1.NotFoundError('run not found');
|
|
174
213
|
const afterSeq = Number(req.query.afterSeq) || 0;
|
|
175
214
|
const limit = Number(req.query.limit) || 1000;
|
|
176
|
-
res.json(await store.getLogs(id, afterSeq, limit));
|
|
215
|
+
res.json(await store.getLogs(id, afterSeq, limit, parseLogFilter(req)));
|
|
216
|
+
});
|
|
217
|
+
/** Whole-run line totals per level — the UI can only hold a trailing window. */
|
|
218
|
+
app.get('/api/runs/:id/logs/counts', async (req, res) => {
|
|
219
|
+
const id = parseId(String(req.params.id));
|
|
220
|
+
if (!await store.getRun(id))
|
|
221
|
+
throw new types_1.NotFoundError('run not found');
|
|
222
|
+
res.json(await store.countLogsByLevel(id));
|
|
223
|
+
});
|
|
224
|
+
// Full log as a plain-text download: the escape hatch when the viewer's
|
|
225
|
+
// in-memory window or filters aren't enough (grep it locally, attach it).
|
|
226
|
+
app.get('/api/runs/:id/logs/download', async (req, res) => {
|
|
227
|
+
const id = parseId(String(req.params.id));
|
|
228
|
+
if (!await store.getRun(id))
|
|
229
|
+
throw new types_1.NotFoundError('run not found');
|
|
230
|
+
const filter = parseLogFilter(req);
|
|
231
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
232
|
+
res.setHeader('Content-Disposition', `attachment; filename="run-${id}.log"`);
|
|
233
|
+
let afterSeq = 0;
|
|
234
|
+
let batch;
|
|
235
|
+
do {
|
|
236
|
+
batch = await store.getLogs(id, afterSeq, LOG_PAGE_SIZE, filter);
|
|
237
|
+
for (const row of batch) {
|
|
238
|
+
const module = row.module ? ` [${row.module}]` : '';
|
|
239
|
+
// pg hands back TIMESTAMPTZ as a Date; JSON responses serialise
|
|
240
|
+
// it for us, a text body has to do it explicitly
|
|
241
|
+
const ts = new Date(row.ts).toISOString();
|
|
242
|
+
res.write(`${ts} ${row.level.toUpperCase().padEnd(5)}${module} ${row.message}\n`);
|
|
243
|
+
}
|
|
244
|
+
if (batch.length > 0)
|
|
245
|
+
afterSeq = batch[batch.length - 1].seq;
|
|
246
|
+
} while (batch.length === LOG_PAGE_SIZE);
|
|
247
|
+
res.end();
|
|
177
248
|
});
|
|
178
249
|
app.post('/api/runs/:id/cancel', async (req, res) => {
|
|
179
250
|
res.json(await runner.cancel(parseId(String(req.params.id))));
|
|
@@ -198,7 +269,12 @@ function createServer(deps) {
|
|
|
198
269
|
if (!run)
|
|
199
270
|
throw new types_1.NotFoundError('run not found');
|
|
200
271
|
sseInit(res);
|
|
272
|
+
// `tail` keeps the replay to the trailing window the viewer can hold —
|
|
273
|
+
// older lines stay reachable through the filter/download endpoints.
|
|
274
|
+
const tail = Number(req.query.tail) || 0;
|
|
201
275
|
let lastSeq = Number(req.query.afterSeq) || 0;
|
|
276
|
+
if (!lastSeq && tail > 0)
|
|
277
|
+
lastSeq = await store.getTailStartSeq(id, tail);
|
|
202
278
|
let replayDone = false;
|
|
203
279
|
const pending = [];
|
|
204
280
|
const sendRow = (row) => {
|
|
@@ -230,9 +306,9 @@ function createServer(deps) {
|
|
|
230
306
|
try {
|
|
231
307
|
let batch;
|
|
232
308
|
do {
|
|
233
|
-
batch = await store.getLogs(id, lastSeq,
|
|
309
|
+
batch = await store.getLogs(id, lastSeq, LOG_PAGE_SIZE);
|
|
234
310
|
batch.forEach(sendRow);
|
|
235
|
-
} while (batch.length ===
|
|
311
|
+
} while (batch.length === LOG_PAGE_SIZE);
|
|
236
312
|
}
|
|
237
313
|
finally {
|
|
238
314
|
replayDone = true;
|
package/dist/controller/store.js
CHANGED
|
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ControllerStore = void 0;
|
|
4
4
|
const pg_1 = require("pg");
|
|
5
5
|
const migrations_1 = require("./migrations");
|
|
6
|
+
/** Neutralise LIKE wildcards so a keyword search stays a literal substring match. */
|
|
7
|
+
function escapeLike(value) {
|
|
8
|
+
return value.replace(/[\\%_]/g, ch => `\\${ch}`);
|
|
9
|
+
}
|
|
6
10
|
/**
|
|
7
11
|
* All Postgres access for controller mode: configs, runs, and run logs.
|
|
8
12
|
*/
|
|
@@ -143,11 +147,44 @@ class ControllerStore {
|
|
|
143
147
|
await this.pool.query(`INSERT INTO d2v_run_logs (run_id, seq, ts, level, module, message) VALUES ${placeholders.join(', ')}
|
|
144
148
|
ON CONFLICT DO NOTHING`, values);
|
|
145
149
|
}
|
|
146
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Page through a run's logs. Filtering happens here rather than in the
|
|
152
|
+
* browser so that a search covers the whole run: the UI only holds a
|
|
153
|
+
* trailing window of a long run's output in memory.
|
|
154
|
+
*/
|
|
155
|
+
async getLogs(runId, afterSeq = 0, limit = 1000, filter = {}) {
|
|
156
|
+
const values = [runId, afterSeq];
|
|
157
|
+
let where = 'run_id = $1 AND seq > $2';
|
|
158
|
+
if (filter.levels?.length) {
|
|
159
|
+
values.push(filter.levels);
|
|
160
|
+
where += ` AND level = ANY($${values.length})`;
|
|
161
|
+
}
|
|
162
|
+
if (filter.keyword) {
|
|
163
|
+
values.push(`%${escapeLike(filter.keyword)}%`);
|
|
164
|
+
where += ` AND (message ILIKE $${values.length} ESCAPE '\\' OR module ILIKE $${values.length} ESCAPE '\\')`;
|
|
165
|
+
}
|
|
166
|
+
values.push(Math.min(limit, 5000));
|
|
147
167
|
const { rows } = await this.pool.query(`SELECT seq, ts, level, module, message FROM d2v_run_logs
|
|
148
|
-
WHERE
|
|
168
|
+
WHERE ${where} ORDER BY seq LIMIT $${values.length}`, values);
|
|
149
169
|
return rows.map((r) => ({ ...r, seq: Number(r.seq) }));
|
|
150
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Seq to replay from so that only the last `tail` lines follow, i.e. the
|
|
173
|
+
* seq of the (tail + 1)-th newest line — 0 when the run is shorter.
|
|
174
|
+
*/
|
|
175
|
+
async getTailStartSeq(runId, tail) {
|
|
176
|
+
const { rows } = await this.pool.query(`SELECT seq FROM d2v_run_logs WHERE run_id = $1 ORDER BY seq DESC OFFSET $2 LIMIT 1`, [runId, Math.max(0, tail)]);
|
|
177
|
+
return rows.length > 0 ? Number(rows[0].seq) : 0;
|
|
178
|
+
}
|
|
179
|
+
/** Per-level line totals for a whole run, for the log viewer's filter chips. */
|
|
180
|
+
async countLogsByLevel(runId) {
|
|
181
|
+
const { rows } = await this.pool.query(`SELECT level, COUNT(*)::bigint AS count FROM d2v_run_logs
|
|
182
|
+
WHERE run_id = $1 GROUP BY level`, [runId]);
|
|
183
|
+
const counts = {};
|
|
184
|
+
for (const row of rows)
|
|
185
|
+
counts[row.level] = Number(row.count);
|
|
186
|
+
return counts;
|
|
187
|
+
}
|
|
151
188
|
async pruneOldLogs(retentionDays) {
|
|
152
189
|
const { rowCount } = await this.pool.query(`DELETE FROM d2v_run_logs USING d2v_runs
|
|
153
190
|
WHERE d2v_run_logs.run_id = d2v_runs.id
|