simple-dynamsoft-mcp 7.6.1 → 7.7.1
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 +2 -0
- package/data/metadata/data-manifest.json +24 -24
- package/data/metadata/dynamsoft_sdks.json +3 -3
- package/package.json +4 -2
- package/scripts/eval-retrieval.mjs +64 -0
- package/scripts/lib/retrieval-eval.mjs +134 -0
- package/scripts/lib/scope-matcher.mjs +17 -0
- package/scripts/prebuild-rag-index.mjs +21 -4
- package/scripts/verify-doc-resources.mjs +2 -1
- package/src/data/repo-map.js +23 -4
- package/src/rag/config.js +4 -6
- package/src/rag/index.js +22 -2
- package/src/rag/lexical-provider.js +105 -53
- package/src/rag/search-utils.js +12 -2
- package/src/rag/vector-cache.js +77 -31
- package/src/server/resource-index/builders.js +56 -1
- package/src/server/resource-index/docs-loader.js +57 -4
- package/src/server/resource-index/shared-docs.js +161 -0
- package/src/server/resource-index.js +57 -37
- package/src/server/tools/register-index-tools.js +4 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Fuse from "fuse.js";
|
|
2
|
-
import { expandQueryTokens, extractSnippet } from "./search-utils.js";
|
|
2
|
+
import { chunkMarkdown, expandQueryTokens, extractSnippet } from "./search-utils.js";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_FUSE_OPTIONS = {
|
|
5
5
|
keys: [
|
|
@@ -12,27 +12,40 @@ const DEFAULT_FUSE_OPTIONS = {
|
|
|
12
12
|
includeScore: true
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Section size and overlap for body scoring; match the embedding chunking so
|
|
16
|
+
// lexical and Gemini see the same units, and so a term cut at a chunk boundary
|
|
17
|
+
// still appears whole in the neighbouring chunk.
|
|
18
|
+
const SECTION_CHARS = 1200;
|
|
19
|
+
const SECTION_OVERLAP_CHARS = 200;
|
|
16
20
|
|
|
17
|
-
// Field-weighted
|
|
18
|
-
// is indexed at low weight so body-only content is findable; the URI's last slug
|
|
21
|
+
// Field-weighted header: title/tags outrank summary; the URI's last slug
|
|
19
22
|
// segment is kept (it is often the most descriptive token) while scheme/version
|
|
20
23
|
// noise is dropped (issue #147).
|
|
21
|
-
function
|
|
24
|
+
function buildHeaderText(entry) {
|
|
22
25
|
const title = entry.title || "";
|
|
23
26
|
const tags = Array.isArray(entry.tags) ? entry.tags.join(" ") : "";
|
|
24
27
|
const summary = entry.summary || "";
|
|
25
|
-
const body = entry.embedText ? String(entry.embedText).slice(0, BODY_INDEX_CHARS) : "";
|
|
26
28
|
const uriSlug = String(entry.uri || "").split("/").filter(Boolean).pop() || "";
|
|
27
|
-
return [
|
|
28
|
-
title, title, title,
|
|
29
|
-
tags, tags,
|
|
30
|
-
summary,
|
|
31
|
-
uriSlug,
|
|
32
|
-
body
|
|
33
|
-
].join(" \n ");
|
|
29
|
+
return [title, title, title, tags, tags, summary, uriSlug].join(" \n ");
|
|
34
30
|
}
|
|
35
31
|
|
|
32
|
+
// The whole body, split on headings (each section keeps its heading path), so
|
|
33
|
+
// content deep in long guides is searchable and a page is ranked by its best
|
|
34
|
+
// matching section instead of by one length-diluted blob.
|
|
35
|
+
function buildBodySections(entry) {
|
|
36
|
+
if (!entry.embedText) return [];
|
|
37
|
+
return chunkMarkdown(entry.embedText, SECTION_CHARS, SECTION_OVERLAP_CHARS, 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_SCOPED_FUSE_INDEXES = 64;
|
|
41
|
+
|
|
42
|
+
// Fuse matches long natural-language questions only through fragments of the
|
|
43
|
+
// pattern, giving a weak, noisy signal at a cost of seconds on large scopes.
|
|
44
|
+
// On the golden set (scripts/eval-retrieval.mjs), skipping it above this length
|
|
45
|
+
// kept recall, raised MRR, and cut warm p95 from ~2.2s to ~0.2s. BM25 carries
|
|
46
|
+
// long queries.
|
|
47
|
+
const MAX_FUSE_QUERY_CHARS = 64;
|
|
48
|
+
|
|
36
49
|
const BM25_K1 = 1.2;
|
|
37
50
|
const BM25_B = 0.75;
|
|
38
51
|
|
|
@@ -61,63 +74,86 @@ function compareLexicalResults(a, b) {
|
|
|
61
74
|
return String(a.entry.uri || "").localeCompare(String(b.entry.uri || ""));
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
function
|
|
65
|
-
const documents = [];
|
|
77
|
+
function buildBm25Stats(units) {
|
|
66
78
|
const documentFrequency = new Map();
|
|
67
79
|
let totalLength = 0;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
tokens.forEach((token) => {
|
|
75
|
-
termFreq.set(token, (termFreq.get(token) || 0) + 1);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const seen = new Set();
|
|
79
|
-
for (const token of tokens) {
|
|
80
|
-
if (seen.has(token)) continue;
|
|
81
|
-
seen.add(token);
|
|
80
|
+
for (const unit of units) {
|
|
81
|
+
unit.termFreq = new Map();
|
|
82
|
+
for (const token of unit.tokens) {
|
|
83
|
+
unit.termFreq.set(token, (unit.termFreq.get(token) || 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
for (const token of unit.termFreq.keys()) {
|
|
82
86
|
documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
|
|
83
87
|
}
|
|
88
|
+
unit.length = unit.tokens.length;
|
|
89
|
+
totalLength += unit.length;
|
|
90
|
+
delete unit.tokens;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
documentFrequency,
|
|
94
|
+
totalDocuments: units.length,
|
|
95
|
+
avgLength: units.length > 0 ? totalLength / units.length : 0
|
|
96
|
+
};
|
|
97
|
+
}
|
|
84
98
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
99
|
+
// Two BM25 indexes: one header unit per entry, one unit per body section.
|
|
100
|
+
// A page scores header + best section.
|
|
101
|
+
function buildBm25Index(entries) {
|
|
102
|
+
const documents = entries.map((entry, index) => ({
|
|
103
|
+
index,
|
|
104
|
+
entry,
|
|
105
|
+
tokens: tokenize(buildHeaderText(entry)),
|
|
106
|
+
sections: [],
|
|
107
|
+
fallbackSnippet: entry.summary || entry.title || ""
|
|
108
|
+
}));
|
|
109
|
+
const sections = [];
|
|
110
|
+
for (const doc of documents) {
|
|
111
|
+
for (const text of buildBodySections(doc.entry)) {
|
|
112
|
+
const section = { text, tokens: tokenize(text) };
|
|
113
|
+
doc.sections.push(section);
|
|
114
|
+
sections.push(section);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
96
117
|
return {
|
|
97
118
|
documents,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
avgLength
|
|
119
|
+
headerStats: buildBm25Stats(documents),
|
|
120
|
+
sectionStats: buildBm25Stats(sections)
|
|
101
121
|
};
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
function computeBm25Score(
|
|
124
|
+
function computeBm25Score(stats, unit, terms) {
|
|
105
125
|
if (terms.length === 0) return 0;
|
|
106
|
-
if (!
|
|
126
|
+
if (!unit.length || !stats.avgLength) return 0;
|
|
107
127
|
|
|
108
128
|
let score = 0;
|
|
109
129
|
for (const term of terms) {
|
|
110
|
-
const tf =
|
|
130
|
+
const tf = unit.termFreq.get(term) || 0;
|
|
111
131
|
if (!tf) continue;
|
|
112
|
-
const df =
|
|
113
|
-
const idf = Math.log(1 + (
|
|
114
|
-
const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (
|
|
132
|
+
const df = stats.documentFrequency.get(term) || 0;
|
|
133
|
+
const idf = Math.log(1 + (stats.totalDocuments - df + 0.5) / (df + 0.5));
|
|
134
|
+
const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (unit.length / stats.avgLength));
|
|
115
135
|
score += idf * ((tf * (BM25_K1 + 1)) / denom);
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
return score;
|
|
119
139
|
}
|
|
120
140
|
|
|
141
|
+
function scoreDocument(index, doc, terms) {
|
|
142
|
+
let best = null;
|
|
143
|
+
let bestScore = 0;
|
|
144
|
+
for (const section of doc.sections) {
|
|
145
|
+
const score = computeBm25Score(index.sectionStats, section, terms);
|
|
146
|
+
if (score > bestScore) {
|
|
147
|
+
bestScore = score;
|
|
148
|
+
best = section;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
score: computeBm25Score(index.headerStats, doc, terms) + bestScore,
|
|
153
|
+
snippetText: best ? best.text : doc.fallbackSnippet
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
121
157
|
function createLexicalProvider({
|
|
122
158
|
entries,
|
|
123
159
|
entryMatchesScope,
|
|
@@ -126,10 +162,24 @@ function createLexicalProvider({
|
|
|
126
162
|
bm25Weight = 0.7,
|
|
127
163
|
fuseWeight = 0.3
|
|
128
164
|
}) {
|
|
129
|
-
const fuse = new Fuse(entries, fuseOptions);
|
|
130
165
|
const bm25Index = buildBm25Index(entries);
|
|
131
166
|
const entryByUri = new Map(entries.map((entry) => [entry.uri, entry]));
|
|
132
|
-
|
|
167
|
+
// Fuse cost grows with the number of entries scanned, and its hits were
|
|
168
|
+
// filtered by scope only afterwards. One Fuse index per scope keeps scoped
|
|
169
|
+
// searches proportional to that scope; the scope set is small and bounded.
|
|
170
|
+
const fuseByScope = new Map();
|
|
171
|
+
const getScopedFuse = (filters) => {
|
|
172
|
+
const key = [filters?.product, filters?.edition, filters?.platform, filters?.type].map((v) => v || "").join("|");
|
|
173
|
+
let scoped = fuseByScope.get(key);
|
|
174
|
+
if (!scoped) {
|
|
175
|
+
scoped = new Fuse(entries.filter((entry) => entryMatchesScope(entry, filters || {})), fuseOptions);
|
|
176
|
+
if (fuseByScope.size >= MAX_SCOPED_FUSE_INDEXES) {
|
|
177
|
+
fuseByScope.delete(fuseByScope.keys().next().value);
|
|
178
|
+
}
|
|
179
|
+
fuseByScope.set(key, scoped);
|
|
180
|
+
}
|
|
181
|
+
return scoped;
|
|
182
|
+
};
|
|
133
183
|
|
|
134
184
|
return {
|
|
135
185
|
name: "lexical",
|
|
@@ -137,7 +187,7 @@ function createLexicalProvider({
|
|
|
137
187
|
// Expand with domain synonyms/stems so vocabulary gaps (webcam/camera,
|
|
138
188
|
// datamatrix/data matrix, scanning/scan) do not zero the BM25 component.
|
|
139
189
|
const terms = expandQueryTokens([...new Set(tokenize(query))]);
|
|
140
|
-
const fuseHits =
|
|
190
|
+
const fuseHits = query.length <= MAX_FUSE_QUERY_CHARS ? getScopedFuse(filters).search(query) : [];
|
|
141
191
|
const fuseScoreByUri = new Map();
|
|
142
192
|
|
|
143
193
|
for (const hit of fuseHits) {
|
|
@@ -154,9 +204,11 @@ function createLexicalProvider({
|
|
|
154
204
|
// out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
|
|
155
205
|
let maxFuse = 0;
|
|
156
206
|
|
|
207
|
+
const snippetTextByUri = new Map();
|
|
157
208
|
for (const doc of bm25Index.documents) {
|
|
158
209
|
if (!entryMatchesScope(doc.entry, filters)) continue;
|
|
159
|
-
const score =
|
|
210
|
+
const { score, snippetText } = scoreDocument(bm25Index, doc, terms);
|
|
211
|
+
snippetTextByUri.set(doc.entry.uri, snippetText);
|
|
160
212
|
if (score > 0) {
|
|
161
213
|
bm25ScoreByUri.set(doc.entry.uri, score);
|
|
162
214
|
if (score > maxBm25) maxBm25 = score;
|
package/src/rag/search-utils.js
CHANGED
|
@@ -78,9 +78,11 @@ function chunkMarkdown(text, chunkSize, chunkOverlap, maxChunks) {
|
|
|
78
78
|
const sections = [];
|
|
79
79
|
let current = { path: "", lines: [] };
|
|
80
80
|
|
|
81
|
+
// Heading-only sections (e.g. index pages whose headings are links) are
|
|
82
|
+
// kept: their heading path is the only text that makes them findable.
|
|
81
83
|
const flush = () => {
|
|
82
84
|
const body = current.lines.join(" ").replace(/\s+/g, " ").trim();
|
|
83
|
-
if (body) sections.push({ path: current.path, body });
|
|
85
|
+
if (body || current.path) sections.push({ path: current.path, body });
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
let inFence = false;
|
|
@@ -116,6 +118,11 @@ function chunkMarkdown(text, chunkSize, chunkOverlap, maxChunks) {
|
|
|
116
118
|
const chunks = [];
|
|
117
119
|
for (const section of sections) {
|
|
118
120
|
const prefix = section.path ? `${section.path}\n` : "";
|
|
121
|
+
if (!section.body) {
|
|
122
|
+
chunks.push(section.path);
|
|
123
|
+
if (maxChunks && chunks.length >= maxChunks) return chunks;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
119
126
|
const pieces = chunkText(section.body, chunkSize, chunkOverlap, maxChunks);
|
|
120
127
|
for (const piece of pieces) {
|
|
121
128
|
chunks.push(`${prefix}${piece}`.trim());
|
|
@@ -248,6 +255,7 @@ function buildIndexSignature({ pkgVersion, signatureData, ragConfig }) {
|
|
|
248
255
|
dbrWebDocCount: signatureData.dbrWebDocCount,
|
|
249
256
|
dbrMobileDocCount: signatureData.dbrMobileDocCount,
|
|
250
257
|
dbrServerDocCount: signatureData.dbrServerDocCount,
|
|
258
|
+
dbrSharedDocCount: signatureData.dbrSharedDocCount,
|
|
251
259
|
dwtDocCount: signatureData.dwtDocCount,
|
|
252
260
|
ddvDocCount: signatureData.ddvDocCount,
|
|
253
261
|
versions: signatureData.versions,
|
|
@@ -257,7 +265,9 @@ function buildIndexSignature({ pkgVersion, signatureData, ragConfig }) {
|
|
|
257
265
|
maxChunksPerDoc: ragConfig.maxChunksPerDoc,
|
|
258
266
|
maxTextChars: ragConfig.maxTextChars,
|
|
259
267
|
// Bump when the chunking strategy changes so cached vector indexes rebuild.
|
|
260
|
-
chunkStrategy: "heading-aware-
|
|
268
|
+
chunkStrategy: "heading-aware-v2",
|
|
269
|
+
// Bump when doc:// URIs change shape; cached vectors are keyed by URI.
|
|
270
|
+
docUriScheme: "source-path-v1"
|
|
261
271
|
});
|
|
262
272
|
}
|
|
263
273
|
|
package/src/rag/vector-cache.js
CHANGED
|
@@ -20,6 +20,70 @@ function makeCheckpointFileName(provider, model, cacheKey) {
|
|
|
20
20
|
return `rag-${provider}-${safeModel}-${cacheKey.slice(0, 12)}.checkpoint.json`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Cache layout: a JSON header (`<name>.json`: cacheKey, meta, items, vector
|
|
24
|
+
// count/dimensions) plus a sibling `<name>.f32` file holding all vectors as
|
|
25
|
+
// packed little-endian float32. Serializing vectors into JSON overflowed V8's
|
|
26
|
+
// ~512MB max string length once the index grew; neither file here is built as
|
|
27
|
+
// one large string.
|
|
28
|
+
const VECTOR_FORMAT = "float32-le-v1";
|
|
29
|
+
|
|
30
|
+
function vectorFileFor(headerFile) {
|
|
31
|
+
return headerFile.replace(/\.json$/i, ".f32");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeVectorFiles(headerFile, payload) {
|
|
35
|
+
const { vectors, ...header } = payload;
|
|
36
|
+
const list = Array.isArray(vectors) ? vectors : [];
|
|
37
|
+
const dimensions = list.length > 0 ? list[0].length : 0;
|
|
38
|
+
const packed = new Float32Array(list.length * dimensions);
|
|
39
|
+
list.forEach((vector, index) => {
|
|
40
|
+
if (vector.length !== dimensions) {
|
|
41
|
+
throw new Error(`Vector ${index} has ${vector.length} dimensions; expected ${dimensions}`);
|
|
42
|
+
}
|
|
43
|
+
packed.set(vector, index * dimensions);
|
|
44
|
+
});
|
|
45
|
+
// Vectors first: a header always describes a complete vector file, and a
|
|
46
|
+
// stale header next to a newer vector file fails the size check on load.
|
|
47
|
+
writeFileSync(vectorFileFor(headerFile), Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength));
|
|
48
|
+
writeFileSync(headerFile, JSON.stringify({
|
|
49
|
+
...header,
|
|
50
|
+
vectorFormat: VECTOR_FORMAT,
|
|
51
|
+
vectorCount: list.length,
|
|
52
|
+
dimensions
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns the parsed header with `vectors` as Float32Array views over one
|
|
57
|
+
// buffer, or { error } describing why the files cannot be used.
|
|
58
|
+
function readVectorFiles(headerFile) {
|
|
59
|
+
const header = JSON.parse(readFileSync(headerFile, "utf8"));
|
|
60
|
+
if (!header || header.vectorFormat !== VECTOR_FORMAT || !Array.isArray(header.items)) {
|
|
61
|
+
return { error: "invalid_payload" };
|
|
62
|
+
}
|
|
63
|
+
const vectorFile = vectorFileFor(headerFile);
|
|
64
|
+
if (!existsSync(vectorFile)) {
|
|
65
|
+
return { error: "vectors_missing" };
|
|
66
|
+
}
|
|
67
|
+
const count = Number(header.vectorCount) || 0;
|
|
68
|
+
const dimensions = Number(header.dimensions) || 0;
|
|
69
|
+
const raw = readFileSync(vectorFile);
|
|
70
|
+
if (raw.byteLength !== count * dimensions * Float32Array.BYTES_PER_ELEMENT) {
|
|
71
|
+
return { error: "vectors_size_mismatch" };
|
|
72
|
+
}
|
|
73
|
+
// Copy into an aligned buffer; Buffer offsets are not guaranteed 4-byte aligned.
|
|
74
|
+
const all = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
|
|
75
|
+
const vectors = new Array(count);
|
|
76
|
+
for (let i = 0; i < count; i += 1) {
|
|
77
|
+
vectors[i] = all.subarray(i * dimensions, (i + 1) * dimensions);
|
|
78
|
+
}
|
|
79
|
+
return { payload: { ...header, vectors } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removeVectorFiles(headerFile) {
|
|
83
|
+
rmSync(headerFile, { force: true });
|
|
84
|
+
rmSync(vectorFileFor(headerFile), { force: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
23
87
|
function loadVectorIndexCache(
|
|
24
88
|
cacheFile,
|
|
25
89
|
{ cacheKey, signature, provider, model, requireSignature = false } = {}
|
|
@@ -28,12 +92,12 @@ function loadVectorIndexCache(
|
|
|
28
92
|
return { hit: false, reason: "missing", payload: null };
|
|
29
93
|
}
|
|
30
94
|
try {
|
|
31
|
-
const parsed =
|
|
32
|
-
if (
|
|
33
|
-
return { hit: false, reason:
|
|
95
|
+
const { payload: parsed, error } = readVectorFiles(cacheFile);
|
|
96
|
+
if (error) {
|
|
97
|
+
return { hit: false, reason: error, payload: null };
|
|
34
98
|
}
|
|
35
|
-
if (
|
|
36
|
-
return { hit: false, reason: "
|
|
99
|
+
if (cacheKey && parsed.cacheKey !== cacheKey) {
|
|
100
|
+
return { hit: false, reason: "cache_key_mismatch", payload: null };
|
|
37
101
|
}
|
|
38
102
|
const meta = parsed.meta || {};
|
|
39
103
|
if (provider && meta.provider && meta.provider !== provider) {
|
|
@@ -59,23 +123,7 @@ function loadVectorIndexCache(
|
|
|
59
123
|
|
|
60
124
|
function saveVectorIndexCache(cacheDir, cacheFile, payload) {
|
|
61
125
|
ensureDirectory(cacheDir);
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
serialized = JSON.stringify(payload);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
// JSON.stringify throws "Invalid string length" once the serialized cache
|
|
67
|
-
// exceeds V8's ~512MB max string length (reached with a large index of
|
|
68
|
-
// high-dimensional vectors). Fail with an actionable message instead.
|
|
69
|
-
if (error instanceof RangeError) {
|
|
70
|
-
const itemCount = Array.isArray(payload?.items) ? payload.items.length : "unknown";
|
|
71
|
-
throw new Error(
|
|
72
|
-
`Vector cache too large to serialize (${itemCount} items) — exceeds the JSON string limit. ` +
|
|
73
|
-
"Lower RAG_MAX_CHUNKS_PER_DOC or adopt a ceiling-proof cache format."
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
78
|
-
writeFileSync(cacheFile, serialized);
|
|
126
|
+
writeVectorFiles(cacheFile, payload);
|
|
79
127
|
}
|
|
80
128
|
|
|
81
129
|
function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
|
|
@@ -83,12 +131,12 @@ function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
|
|
|
83
131
|
return { hit: false, reason: "missing", payload: null };
|
|
84
132
|
}
|
|
85
133
|
try {
|
|
86
|
-
const parsed =
|
|
87
|
-
if (
|
|
88
|
-
return { hit: false, reason:
|
|
134
|
+
const { payload: parsed, error } = readVectorFiles(checkpointFile);
|
|
135
|
+
if (error) {
|
|
136
|
+
return { hit: false, reason: error, payload: null };
|
|
89
137
|
}
|
|
90
|
-
if (
|
|
91
|
-
return { hit: false, reason: "
|
|
138
|
+
if (parsed.cacheKey !== expectedKey) {
|
|
139
|
+
return { hit: false, reason: "cache_key_mismatch", payload: null };
|
|
92
140
|
}
|
|
93
141
|
if (parsed.items.length !== expectedItems.length) {
|
|
94
142
|
return { hit: false, reason: "items_length_mismatch", payload: null };
|
|
@@ -109,13 +157,11 @@ function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
|
|
|
109
157
|
|
|
110
158
|
function saveVectorIndexCheckpoint(cacheDir, checkpointFile, payload) {
|
|
111
159
|
ensureDirectory(cacheDir);
|
|
112
|
-
|
|
160
|
+
writeVectorFiles(checkpointFile, payload);
|
|
113
161
|
}
|
|
114
162
|
|
|
115
163
|
function clearVectorIndexCheckpoint(checkpointFile) {
|
|
116
|
-
|
|
117
|
-
rmSync(checkpointFile, { force: true });
|
|
118
|
-
}
|
|
164
|
+
removeVectorFiles(checkpointFile);
|
|
119
165
|
}
|
|
120
166
|
|
|
121
167
|
function listFilesRecursive(rootDir) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, join } from "node:path";
|
|
3
3
|
import { DDV_PREFERRED_ENTRY_FILES } from "./config.js";
|
|
4
|
+
import { shortDocPath } from "./docs-loader.js";
|
|
4
5
|
import { PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
|
|
5
6
|
|
|
6
7
|
const MRZ_MATCHER = /(?:\bmrz\b|machine[-\s]?readable[-\s]?zone|passport)/i;
|
|
@@ -201,6 +202,29 @@ function addPublicResourceToIndex(addResourceToIndex, entry) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
function encodeDocSlug(slug) {
|
|
206
|
+
return String(slug).split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// One doc:// slug per article: its short path, or (on a collision within the
|
|
210
|
+
// same URI prefix + platform, e.g. `user-guide.md` vs `user-guide/index.md`)
|
|
211
|
+
// its full source path. Shared pages keep their `slugPrefix` namespace
|
|
212
|
+
// (`dcv`, `dcv-core`) in both forms.
|
|
213
|
+
function assignDocSlugs(docs, defaultPlatform) {
|
|
214
|
+
const withPrefix = (article, slug) => (article.slugPrefix ? `${article.slugPrefix}/${slug}` : slug);
|
|
215
|
+
const keyFor = (article, slug) => `${article.platform || defaultPlatform}/${slug}`;
|
|
216
|
+
const shortSlugs = docs.map((article) => withPrefix(article, shortDocPath(article.path || article.title)));
|
|
217
|
+
const counts = new Map();
|
|
218
|
+
docs.forEach((article, i) => {
|
|
219
|
+
const key = keyFor(article, shortSlugs[i]);
|
|
220
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
221
|
+
});
|
|
222
|
+
return docs.map((article, i) => {
|
|
223
|
+
if (counts.get(keyFor(article, shortSlugs[i])) === 1) return shortSlugs[i];
|
|
224
|
+
return withPrefix(article, String(article.path || article.title).replace(/\\/g, "/").replace(/\.md$/i, ""));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
204
228
|
function addMarkdownDocResources({
|
|
205
229
|
addResourceToIndex,
|
|
206
230
|
docs,
|
|
@@ -214,12 +238,18 @@ function addMarkdownDocResources({
|
|
|
214
238
|
defaultSummary,
|
|
215
239
|
baseTags
|
|
216
240
|
}) {
|
|
241
|
+
const slugs = assignDocSlugs(docs, defaultPlatform);
|
|
217
242
|
for (let i = 0; i < docs.length; i++) {
|
|
218
243
|
const article = docs[i];
|
|
219
244
|
if (!article?.title) continue;
|
|
220
|
-
const slug =
|
|
245
|
+
const slug = encodeDocSlug(slugs[i]);
|
|
221
246
|
const platform = article.platform || defaultPlatform;
|
|
222
247
|
const tags = [...baseTags, platform];
|
|
248
|
+
if (Array.isArray(article.platforms)) {
|
|
249
|
+
for (const extra of article.platforms) {
|
|
250
|
+
if (!tags.includes(extra)) tags.push(extra);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
223
253
|
if (article.breadcrumb) tags.push(...article.breadcrumb.toLowerCase().split(/\s*>\s*/));
|
|
224
254
|
// Fold frontmatter keywords into tags so domain-vocabulary search hits (#142).
|
|
225
255
|
if (Array.isArray(article.keywords)) {
|
|
@@ -239,7 +269,9 @@ function addMarkdownDocResources({
|
|
|
239
269
|
product,
|
|
240
270
|
edition,
|
|
241
271
|
platform,
|
|
272
|
+
...(Array.isArray(article.platforms) ? { platforms: article.platforms } : {}),
|
|
242
273
|
version,
|
|
274
|
+
...(article.sourceVersion ? { sourceVersion: article.sourceVersion } : {}),
|
|
243
275
|
majorVersion,
|
|
244
276
|
url: article.url,
|
|
245
277
|
title: product === "mrz" || product === "mds"
|
|
@@ -601,6 +633,7 @@ function buildResourceIndex({
|
|
|
601
633
|
dbrWebDocs,
|
|
602
634
|
dbrMobileDocs,
|
|
603
635
|
dbrServerDocs,
|
|
636
|
+
dbrSharedDocs,
|
|
604
637
|
dwtDocs,
|
|
605
638
|
ddvDocs,
|
|
606
639
|
discoverDcvMobileSamples,
|
|
@@ -967,6 +1000,27 @@ function buildResourceIndex({
|
|
|
967
1000
|
baseTags: ["doc", "dbr", "server"]
|
|
968
1001
|
});
|
|
969
1002
|
|
|
1003
|
+
const sharedEditions = [
|
|
1004
|
+
["web", dbrWebVersion, "Web"],
|
|
1005
|
+
["mobile", dbrMobileVersion, "Mobile"],
|
|
1006
|
+
["server", dbrServerVersion, "Server/Desktop"]
|
|
1007
|
+
];
|
|
1008
|
+
for (const [edition, version, label] of sharedEditions) {
|
|
1009
|
+
addMarkdownDocResources({
|
|
1010
|
+
addResourceToIndex,
|
|
1011
|
+
docs: dbrSharedDocs?.[edition] || [],
|
|
1012
|
+
idPrefix: `dbr-${edition}-shared-doc`,
|
|
1013
|
+
uriPrefix: `doc://dbr/${edition}`,
|
|
1014
|
+
product: "dbr",
|
|
1015
|
+
edition,
|
|
1016
|
+
version,
|
|
1017
|
+
majorVersion: LATEST_MAJOR.dbr,
|
|
1018
|
+
defaultPlatform: edition,
|
|
1019
|
+
defaultSummary: `Shared Capture Vision API used by Dynamsoft Barcode Reader ${label}`,
|
|
1020
|
+
baseTags: ["doc", "dbr", edition, "shared", "capture-vision"]
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
970
1024
|
for (const platform of getDbrMobilePlatforms()) {
|
|
971
1025
|
const samples = discoverMobileSamples(platform);
|
|
972
1026
|
for (const level of ["high-level", "low-level"]) {
|
|
@@ -1155,6 +1209,7 @@ function buildResourceIndex({
|
|
|
1155
1209
|
}
|
|
1156
1210
|
|
|
1157
1211
|
export {
|
|
1212
|
+
assignDocSlugs,
|
|
1158
1213
|
buildIndexData,
|
|
1159
1214
|
buildResourceIndex
|
|
1160
1215
|
};
|
|
@@ -98,9 +98,56 @@ function markdownPathToUrl(baseUrl, relativePath) {
|
|
|
98
98
|
return `${baseUrl}${normalized}`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// Path used in doc:// URIs. The platform is already a URI segment, so the
|
|
102
|
+
// `programming/<lang>/` prefix is dropped, and a directory's index page is
|
|
103
|
+
// addressed by the directory.
|
|
104
|
+
function shortDocPath(relativePath) {
|
|
105
|
+
const withoutExt = String(relativePath || "").replace(/\\/g, "/").replace(/\.md$/i, "");
|
|
106
|
+
const withoutPlatform = withoutExt.replace(/^programming\/[^/]+\//, "");
|
|
107
|
+
const withoutIndex = withoutPlatform.replace(/(^|\/)index$/, "");
|
|
108
|
+
return withoutIndex || "index";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SITE_ORIGIN = "https://www.dynamsoft.com";
|
|
112
|
+
|
|
113
|
+
// Top-level `key: value` pairs from a Jekyll _config.yml. Only path/URL values
|
|
114
|
+
// are kept: they are what docs interpolate as `{{ site.key }}` link prefixes.
|
|
115
|
+
function loadSiteVariables(siteRoot) {
|
|
116
|
+
const vars = new Map();
|
|
117
|
+
const configPath = join(siteRoot || "", "_config.yml");
|
|
118
|
+
if (!siteRoot || !existsSync(configPath)) return vars;
|
|
119
|
+
for (const line of readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
120
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*["']?([^"'#\s]+)["']?\s*(?:#.*)?$/);
|
|
121
|
+
if (!match) continue;
|
|
122
|
+
const [, key, value] = match;
|
|
123
|
+
if (value.startsWith("/")) {
|
|
124
|
+
vars.set(key, `${SITE_ORIGIN}${value}`);
|
|
125
|
+
} else if (/^https?:\/\//i.test(value)) {
|
|
126
|
+
vars.set(key, value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return vars;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Replace `{{ site.key }}` with the absolute URL from _config.yml so links in
|
|
133
|
+
// returned docs (and the shared-doc link map) point at the real public pages.
|
|
134
|
+
// Unknown keys are left untouched.
|
|
135
|
+
function resolveSiteLinks(text, siteVars) {
|
|
136
|
+
if (!text || !siteVars || siteVars.size === 0) return text;
|
|
137
|
+
return String(text).replace(/\{\{\s*site\.([A-Za-z0-9_-]+)\s*\}\}/g, (whole, key) => siteVars.get(key) ?? whole);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loadMarkdownDocs({
|
|
141
|
+
rootDir,
|
|
142
|
+
urlBase,
|
|
143
|
+
siteRoot = rootDir,
|
|
144
|
+
includeDirNames = [],
|
|
145
|
+
excludeDirs = [],
|
|
146
|
+
excludeFiles = []
|
|
147
|
+
}) {
|
|
102
148
|
if (!existsSync(rootDir)) return { articles: [] };
|
|
103
149
|
|
|
150
|
+
const siteVars = loadSiteVariables(siteRoot);
|
|
104
151
|
const files = collectMarkdownFiles(rootDir, { includeDirNames, excludeDirs, excludeFiles });
|
|
105
152
|
const articles = [];
|
|
106
153
|
|
|
@@ -108,6 +155,9 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
|
|
|
108
155
|
const relativePath = relative(rootDir, filePath).replace(/\\/g, "/");
|
|
109
156
|
const raw = readFileSync(filePath, "utf8");
|
|
110
157
|
const parsed = parseFrontMatter(raw);
|
|
158
|
+
// Jekyll redirect stubs have no content of their own; indexing them yields
|
|
159
|
+
// empty hits whose URL only redirects elsewhere.
|
|
160
|
+
if (parsed.meta.redirect_to) continue;
|
|
111
161
|
const title = parsed.meta.title || getHeadingTitle(parsed.body) || formatSegmentLabel(relativePath.replace(/\.md$/i, "").split("/").pop());
|
|
112
162
|
if (!title) continue;
|
|
113
163
|
const breadcrumb = parsed.meta.breadcrumbText || buildBreadcrumbFromPath(relativePath) || title;
|
|
@@ -121,7 +171,7 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
|
|
|
121
171
|
articles.push({
|
|
122
172
|
title,
|
|
123
173
|
url: markdownPathToUrl(urlBase, relativePath),
|
|
124
|
-
content: parsed.body.trim(),
|
|
174
|
+
content: resolveSiteLinks(parsed.body.trim(), siteVars),
|
|
125
175
|
breadcrumb,
|
|
126
176
|
description,
|
|
127
177
|
keywords,
|
|
@@ -129,9 +179,12 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
|
|
|
129
179
|
});
|
|
130
180
|
}
|
|
131
181
|
|
|
132
|
-
return { articles };
|
|
182
|
+
return { articles, siteVars };
|
|
133
183
|
}
|
|
134
184
|
|
|
135
185
|
export {
|
|
136
|
-
loadMarkdownDocs
|
|
186
|
+
loadMarkdownDocs,
|
|
187
|
+
loadSiteVariables,
|
|
188
|
+
resolveSiteLinks,
|
|
189
|
+
shortDocPath
|
|
137
190
|
};
|