simple-dynamsoft-mcp 7.7.5 → 7.7.6
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 +3 -3
- package/package.json +2 -2
- package/scripts/eval-retrieval.mjs +28 -3
- package/scripts/lib/retrieval-eval.mjs +41 -3
- package/src/rag/index.js +2 -2
- package/src/rag/lexical-provider.js +11 -8
- package/src/rag/providers.js +11 -8
- package/src/rag/search-utils.js +56 -1
- package/src/server/create-server.js +2 -2
- package/src/server/helpers/search-response.js +136 -0
- package/src/server/normalizers.js +31 -0
- package/src/server/public-offerings.js +11 -0
- package/src/server/resource-index/builders.js +12 -2
- package/src/server/tools/register-document-tools.js +3 -2
- package/src/server/tools/register-index-tools.js +34 -78
package/README.md
CHANGED
|
@@ -295,15 +295,15 @@ mcpServers:
|
|
|
295
295
|
|
|
296
296
|
The server exposes this minimal tool surface:
|
|
297
297
|
|
|
298
|
-
- `get_index` -- compact product/version/sample index with selection guidance
|
|
299
|
-
- `search` -- semantic search across docs and samples (also accepts exact sample IDs); returns `doc://` and `sample://` links
|
|
298
|
+
- `get_index` -- compact product/version/sample index with selection guidance and known capability facts (OCR, VIN, ID documents)
|
|
299
|
+
- `search` -- semantic search across docs and samples (also accepts exact sample IDs); returns `doc://` and `sample://` links plus the matched passage (up to ~1500 chars) of the top 3 results (`content: "none"` returns links and short snippets only). Without `edition`/`platform`, MRZ and MDS search their web edition, and a DBR query that names exactly one edition (for example "web app" or "Android") searches that edition and says so
|
|
300
300
|
- `read_document` -- read the full text (title, public docs URL, body) of a `doc://` URI returned by `search`
|
|
301
301
|
- `list_samples` -- browse available samples for a product/edition/platform
|
|
302
302
|
- `resolve_version` -- resolve current version for a product/edition/platform
|
|
303
303
|
- `get_quickstart` -- opinionated quickstart: picks a sample by scenario, returns code + install instructions
|
|
304
304
|
- `get_sample_files` -- get full project files for a known sample (discovered via list_samples or search)
|
|
305
305
|
|
|
306
|
-
Typical doc Q&A flow: `search` -> `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
|
|
306
|
+
Typical doc Q&A flow: `search` -> answer from a matched passage when it covers the question, otherwise `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
|
|
307
307
|
|
|
308
308
|
DBR searches also cover the shared Capture Vision API pages that the DBR docs link to (for example `CaptureVisionRouter.capture()` and `getSimplifiedSettings()`/`updateSettings()`); these hits keep their official `capture-vision/docs` URL and are labelled `shared Capture Vision API used by DBR` in search results. Doc URIs are path-based (`doc://dbr/web/web/<version>/user-guide/use-in-framework`) and change only when the docs move or the SDK version changes.
|
|
309
309
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-dynamsoft-mcp",
|
|
3
|
-
"version": "7.7.
|
|
3
|
+
"version": "7.7.6",
|
|
4
4
|
"description": "MCP server for Dynamsoft SDKs - Capture Vision, Barcode Reader (Mobile/Python/Web), Dynamic Web TWAIN, and Document Viewer. Provides documentation, code snippets, and API guidance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"start": "node src/index.js",
|
|
26
26
|
"test": "npm run test:lite",
|
|
27
|
-
"test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js test/unit/incremental-embedding.test.js",
|
|
27
|
+
"test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js test/unit/incremental-embedding.test.js test/unit/search-scope.test.js",
|
|
28
28
|
"test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
|
|
29
29
|
"test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
|
|
30
30
|
"test:gemini": "node scripts/run-gemini-tests.mjs",
|
|
@@ -30,19 +30,43 @@ if (cases.length === 0) {
|
|
|
30
30
|
process.exit(1);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const { ensureResourceIndexReady } = await import("../src/server/resource-index.js");
|
|
33
|
+
const { ensureResourceIndexReady, formatScopeLabel, getSampleIdFromUri } = await import("../src/server/resource-index.js");
|
|
34
34
|
const { searchResources } = await import("../src/rag/index.js");
|
|
35
35
|
const { runRetrievalEval } = await import("./lib/retrieval-eval.mjs");
|
|
36
36
|
const { isResultInScope } = await import("./lib/scope-matcher.mjs");
|
|
37
|
+
const { buildSearchResultsResponse, createHitDescriber, runScopedSearch, searchResponseText } = await import("../src/server/helpers/search-response.js");
|
|
38
|
+
|
|
39
|
+
const describeHit = createHitDescriber({ formatScopeLabel, getSampleIdFromUri });
|
|
40
|
+
// The text of the search tool's own response for these results (eval cases
|
|
41
|
+
// pass already-normalized product filters).
|
|
42
|
+
const renderResponse = (testCase, results, contentMode) => searchResponseText(buildSearchResultsResponse({
|
|
43
|
+
query: testCase.query,
|
|
44
|
+
product: testCase.filters?.product || "",
|
|
45
|
+
results,
|
|
46
|
+
contentMode,
|
|
47
|
+
describeHit,
|
|
48
|
+
getSampleIdFromUri
|
|
49
|
+
}));
|
|
37
50
|
|
|
38
51
|
ensureResourceIndexReady();
|
|
39
|
-
|
|
52
|
+
// Search in the scope the tool would use (edition defaults read from the query).
|
|
53
|
+
const scopedSearch = async (params) => (await runScopedSearch(searchResources, params)).results;
|
|
54
|
+
const report = await runRetrievalEval({
|
|
55
|
+
searchResources: scopedSearch,
|
|
56
|
+
cases,
|
|
57
|
+
k: golden.k || 5,
|
|
58
|
+
isInScope: isResultInScope,
|
|
59
|
+
renderResponse
|
|
60
|
+
});
|
|
40
61
|
report.provider = provider;
|
|
41
62
|
|
|
42
63
|
const fmt = (value) => (value === null || value === undefined ? "n/a" : value.toFixed(3));
|
|
43
64
|
for (const item of report.cases) {
|
|
44
65
|
const flag = item.gap ? "GAP " : item.requiredHit === false ? "MISS" : "ok ";
|
|
45
|
-
|
|
66
|
+
const answer = item.answerInPassages === null
|
|
67
|
+
? ""
|
|
68
|
+
: ` answer: snippets=${item.answerInSnippets ? "y" : "n"} passages=${item.answerInPassages ? "y" : "n"}`;
|
|
69
|
+
console.log(`${flag} ${item.id.padEnd(34)} recall=${fmt(item.recall)} mrr=${fmt(item.mrr)} ndcg=${fmt(item.ndcg)} dup=${item.duplicates} off=${item.offScope} rn=${item.releaseNotes} ms=${item.latencyMs.toFixed(0)}${answer}`);
|
|
46
70
|
if (args.show || item.requiredHit === false) {
|
|
47
71
|
for (const hit of item.top) {
|
|
48
72
|
console.log(` ${hit.rank}. [g${hit.grade}] [${hit.product}] ${hit.title} :: ${hit.url || hit.uri}`);
|
|
@@ -55,6 +79,7 @@ const s = report.summary;
|
|
|
55
79
|
console.log(`\nprovider=${provider} cases=${s.cases} (gap=${s.gapCases}) k=${report.k}`);
|
|
56
80
|
console.log(`recall@${report.k}=${fmt(s.recallAtK)} mrr=${fmt(s.mrr)} ndcg@${report.k}=${fmt(s.ndcgAtK)} required_hit=${fmt(s.requiredHitRate)}`);
|
|
57
81
|
console.log(`duplicate_rate=${fmt(s.duplicateRate)} off_scope_rate=${fmt(s.offScopeRate)} release_notes_rate=${fmt(s.releaseNotesRate)}`);
|
|
82
|
+
console.log(`answer_in_text cases=${s.answerCases} snippets=${fmt(s.answerInSnippetsRate)} passages=${fmt(s.answerInPassagesRate)} | mean_chars snippets=${fmt(s.meanSnippetChars)} passages=${fmt(s.meanPassageChars)}`);
|
|
58
83
|
const lat = s.latencyMs;
|
|
59
84
|
console.log(`latency_ms first_query=${lat.firstQuery.toFixed(0)} first_pass p50=${lat.p50FirstPass.toFixed(0)} p95=${lat.p95FirstPass.toFixed(0)} | warm p50=${lat.p50Warm.toFixed(0)} p95=${lat.p95Warm.toFixed(0)}`);
|
|
60
85
|
|
|
@@ -25,9 +25,12 @@ function percentile(values, p) {
|
|
|
25
25
|
|
|
26
26
|
// `isInScope(result, filters)` (when given) applies the server's own
|
|
27
27
|
// product/edition/platform matching so cross-platform leakage is counted too.
|
|
28
|
+
// `expectedEdition` checks the edition the server should pick on its own
|
|
29
|
+
// (cases that pass no edition filter), independently of the filters.
|
|
28
30
|
function isOffScope(result, testCase, isInScope) {
|
|
29
31
|
const product = testCase.filters?.product;
|
|
30
32
|
if (product && result.product !== product) return true;
|
|
33
|
+
if (testCase.expectedEdition && result.edition !== testCase.expectedEdition) return true;
|
|
31
34
|
if (isInScope && !isInScope(result, testCase.filters || {})) return true;
|
|
32
35
|
const prefixes = testCase.allowedUrlPrefixes;
|
|
33
36
|
if (Array.isArray(prefixes) && prefixes.length > 0 && result.url) {
|
|
@@ -40,7 +43,30 @@ function isReleaseNotes(result) {
|
|
|
40
43
|
return /\/release-notes?\//i.test(String(result.url || ""));
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function
|
|
46
|
+
function normalizeForMatch(text) {
|
|
47
|
+
return String(text || "").toLowerCase().replace(/\s+/g, " ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// What an agent reads from the search tool's actual response in each content
|
|
51
|
+
// mode. `renderResponse(testCase, results, contentMode)` returns that text
|
|
52
|
+
// (scripts/eval-retrieval.mjs builds it with the tool's own response builder).
|
|
53
|
+
function emittedText(testCase, results, k, renderResponse) {
|
|
54
|
+
if (!renderResponse) return { snippet: "", passage: "" };
|
|
55
|
+
const top = results.slice(0, k);
|
|
56
|
+
return {
|
|
57
|
+
snippet: renderResponse(testCase, top, "none"),
|
|
58
|
+
passage: renderResponse(testCase, top, "passage")
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// answerTerms: groups of alternatives; the answer counts as present when
|
|
63
|
+
// every group has at least one alternative in the text (case-insensitive).
|
|
64
|
+
function answerPresent(text, answerTerms) {
|
|
65
|
+
const haystack = normalizeForMatch(text);
|
|
66
|
+
return answerTerms.every((group) => group.some((term) => haystack.includes(normalizeForMatch(term))));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreCase(testCase, results, k, isInScope, renderResponse) {
|
|
44
70
|
const relevant = new Map(
|
|
45
71
|
Object.entries(testCase.relevant || {}).map(([url, grade]) => [normalizeUrl(url), Number(grade) || 0])
|
|
46
72
|
);
|
|
@@ -57,6 +83,8 @@ function scoreCase(testCase, results, k, isInScope) {
|
|
|
57
83
|
|
|
58
84
|
const urls = top.map((result) => normalizeUrl(result.url || result.uri));
|
|
59
85
|
const duplicates = urls.length - new Set(urls).size;
|
|
86
|
+
const hasAnswer = Array.isArray(testCase.answerTerms) && testCase.answerTerms.length > 0;
|
|
87
|
+
const emitted = emittedText(testCase, results, k, renderResponse);
|
|
60
88
|
|
|
61
89
|
return {
|
|
62
90
|
id: testCase.id,
|
|
@@ -70,6 +98,10 @@ function scoreCase(testCase, results, k, isInScope) {
|
|
|
70
98
|
offScope: top.filter((result) => isOffScope(result, testCase, isInScope)).length,
|
|
71
99
|
releaseNotes: top.filter(isReleaseNotes).length,
|
|
72
100
|
returned: top.length,
|
|
101
|
+
answerInSnippets: hasAnswer ? answerPresent(emitted.snippet, testCase.answerTerms) : null,
|
|
102
|
+
answerInPassages: hasAnswer ? answerPresent(emitted.passage, testCase.answerTerms) : null,
|
|
103
|
+
snippetChars: emitted.snippet.length,
|
|
104
|
+
passageChars: emitted.passage.length,
|
|
73
105
|
top: top.map((result, index) => ({
|
|
74
106
|
rank: index + 1,
|
|
75
107
|
grade: gains[index],
|
|
@@ -90,6 +122,7 @@ function mean(values) {
|
|
|
90
122
|
function summarize(caseScores, firstPass, warmPass) {
|
|
91
123
|
const totalReturned = caseScores.reduce((sum, item) => sum + item.returned, 0) || 1;
|
|
92
124
|
const required = caseScores.filter((item) => item.requiredHit !== null);
|
|
125
|
+
const answered = caseScores.filter((item) => item.answerInPassages !== null);
|
|
93
126
|
return {
|
|
94
127
|
cases: caseScores.length,
|
|
95
128
|
gapCases: caseScores.filter((item) => item.gap).length,
|
|
@@ -100,6 +133,11 @@ function summarize(caseScores, firstPass, warmPass) {
|
|
|
100
133
|
duplicateRate: caseScores.reduce((sum, item) => sum + item.duplicates, 0) / totalReturned,
|
|
101
134
|
offScopeRate: caseScores.reduce((sum, item) => sum + item.offScope, 0) / totalReturned,
|
|
102
135
|
releaseNotesRate: caseScores.reduce((sum, item) => sum + item.releaseNotes, 0) / totalReturned,
|
|
136
|
+
answerCases: answered.length,
|
|
137
|
+
answerInSnippetsRate: answered.length ? answered.filter((item) => item.answerInSnippets).length / answered.length : null,
|
|
138
|
+
answerInPassagesRate: answered.length ? answered.filter((item) => item.answerInPassages).length / answered.length : null,
|
|
139
|
+
meanSnippetChars: mean(caseScores.map((item) => item.snippetChars)),
|
|
140
|
+
meanPassageChars: mean(caseScores.map((item) => item.passageChars)),
|
|
103
141
|
// First pass pays index loading and per-scope index builds; the warm pass
|
|
104
142
|
// repeats the same queries, so it reflects steady-state search cost.
|
|
105
143
|
latencyMs: {
|
|
@@ -112,7 +150,7 @@ function summarize(caseScores, firstPass, warmPass) {
|
|
|
112
150
|
};
|
|
113
151
|
}
|
|
114
152
|
|
|
115
|
-
async function runRetrievalEval({ searchResources, cases, k = 5, isInScope }) {
|
|
153
|
+
async function runRetrievalEval({ searchResources, cases, k = 5, isInScope, renderResponse }) {
|
|
116
154
|
const caseScores = [];
|
|
117
155
|
const firstPass = [];
|
|
118
156
|
const warmPass = [];
|
|
@@ -121,7 +159,7 @@ async function runRetrievalEval({ searchResources, cases, k = 5, isInScope }) {
|
|
|
121
159
|
const results = await searchResources({ query: testCase.query, ...(testCase.filters || {}), limit: k, throwOnError: true });
|
|
122
160
|
const latencyMs = performance.now() - startedAt;
|
|
123
161
|
firstPass.push(latencyMs);
|
|
124
|
-
caseScores.push({ ...scoreCase(testCase, results, k, isInScope), latencyMs });
|
|
162
|
+
caseScores.push({ ...scoreCase(testCase, results, k, isInScope, renderResponse), latencyMs });
|
|
125
163
|
}
|
|
126
164
|
for (const testCase of cases) {
|
|
127
165
|
const startedAt = performance.now();
|
package/src/rag/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
dotProduct,
|
|
31
31
|
isRateLimitError,
|
|
32
32
|
expandQueryTokens,
|
|
33
|
-
|
|
33
|
+
buildMatchFields
|
|
34
34
|
} from "./search-utils.js";
|
|
35
35
|
import { createProviderOrchestrator } from "./providers.js";
|
|
36
36
|
import { createVectorCacheHelpers } from "./vector-cache.js";
|
|
@@ -46,7 +46,7 @@ const searchUtils = {
|
|
|
46
46
|
dotProduct,
|
|
47
47
|
isRateLimitError,
|
|
48
48
|
expandQueryTokens,
|
|
49
|
-
|
|
49
|
+
buildMatchFields,
|
|
50
50
|
entryMatchesScope: (entry, filters) => entryMatchesScope(entry, filters, {
|
|
51
51
|
editionMatches,
|
|
52
52
|
platformMatches
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Fuse from "fuse.js";
|
|
2
|
-
import { chunkMarkdown, expandQueryTokens
|
|
2
|
+
import { buildMatchFields, chunkMarkdown, expandQueryTokens } from "./search-utils.js";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_FUSE_OPTIONS = {
|
|
5
5
|
keys: [
|
|
@@ -150,7 +150,8 @@ function scoreDocument(index, doc, terms) {
|
|
|
150
150
|
}
|
|
151
151
|
return {
|
|
152
152
|
score: computeBm25Score(index.headerStats, doc, terms) + bestScore,
|
|
153
|
-
|
|
153
|
+
passageText: best ? best.text : "",
|
|
154
|
+
fallbackSnippet: doc.fallbackSnippet
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
157
|
|
|
@@ -204,11 +205,11 @@ function createLexicalProvider({
|
|
|
204
205
|
// out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
|
|
205
206
|
let maxFuse = 0;
|
|
206
207
|
|
|
207
|
-
const
|
|
208
|
+
const matchByUri = new Map();
|
|
208
209
|
for (const doc of bm25Index.documents) {
|
|
209
210
|
if (!entryMatchesScope(doc.entry, filters)) continue;
|
|
210
|
-
const { score,
|
|
211
|
-
|
|
211
|
+
const { score, passageText, fallbackSnippet } = scoreDocument(bm25Index, doc, terms);
|
|
212
|
+
matchByUri.set(doc.entry.uri, { passageText, fallbackSnippet });
|
|
212
213
|
if (score > 0) {
|
|
213
214
|
bm25ScoreByUri.set(doc.entry.uri, score);
|
|
214
215
|
if (score > maxBm25) maxBm25 = score;
|
|
@@ -237,9 +238,11 @@ function createLexicalProvider({
|
|
|
237
238
|
|
|
238
239
|
merged.sort(compareLexicalResults);
|
|
239
240
|
const ranked = merged.map((item) => {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
const match = matchByUri.get(item.entry.uri) || {};
|
|
242
|
+
return {
|
|
243
|
+
...attachScore(item.entry, item.score),
|
|
244
|
+
...buildMatchFields(match.passageText, terms, match.fallbackSnippet)
|
|
245
|
+
};
|
|
243
246
|
});
|
|
244
247
|
if (limit) return ranked.slice(0, limit);
|
|
245
248
|
return ranked;
|
package/src/rag/providers.js
CHANGED
|
@@ -300,6 +300,9 @@ function createProviderOrchestrator({
|
|
|
300
300
|
uri: item.uri,
|
|
301
301
|
hash: createHash("sha256").update(`${model}\n${item.text}`).digest("hex").slice(0, 32)
|
|
302
302
|
}));
|
|
303
|
+
// Passages stay in memory only (not in the cache file): they come from
|
|
304
|
+
// the current docs, parallel to indexedItems.
|
|
305
|
+
const passages = items.map((item) => item.passage || "");
|
|
303
306
|
const checkpointHashes = new Set();
|
|
304
307
|
const collect = () => (ragConfig.rebuild
|
|
305
308
|
? new Map()
|
|
@@ -338,7 +341,7 @@ function createProviderOrchestrator({
|
|
|
338
341
|
vectorCache.clearVectorIndexCheckpoint(checkpointFile);
|
|
339
342
|
vectorCache.pruneOtherCaches({ provider: name, model, keepFile: cacheFile });
|
|
340
343
|
logRag(`cache hit provider=${name} file=${cacheFile} items=${indexedItems.length}`);
|
|
341
|
-
return { items: indexedItems, vectors };
|
|
344
|
+
return { items: indexedItems, vectors, passages };
|
|
342
345
|
}
|
|
343
346
|
|
|
344
347
|
if (name === "gemini" && embedder.resetMetrics) {
|
|
@@ -431,7 +434,8 @@ function createProviderOrchestrator({
|
|
|
431
434
|
);
|
|
432
435
|
return {
|
|
433
436
|
items: payload.items,
|
|
434
|
-
vectors: payload.vectors
|
|
437
|
+
vectors: payload.vectors,
|
|
438
|
+
passages
|
|
435
439
|
};
|
|
436
440
|
})();
|
|
437
441
|
return indexPromise;
|
|
@@ -462,7 +466,7 @@ function createProviderOrchestrator({
|
|
|
462
466
|
if (!entry || !utils.entryMatchesScope(entry, filters)) continue;
|
|
463
467
|
const existing = bestByUri.get(item.uri);
|
|
464
468
|
if (!existing || score > existing.score) {
|
|
465
|
-
bestByUri.set(item.uri, { entry, score,
|
|
469
|
+
bestByUri.set(item.uri, { entry, score, passage: index.passages[i] });
|
|
466
470
|
}
|
|
467
471
|
}
|
|
468
472
|
|
|
@@ -477,11 +481,10 @@ function createProviderOrchestrator({
|
|
|
477
481
|
const kept = ordered.filter((item) => item.score >= relativeFloor);
|
|
478
482
|
|
|
479
483
|
const snippetTerms = baseQuery.toLowerCase().split(/\s+/).filter(Boolean);
|
|
480
|
-
const results = kept.map((item) => {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
});
|
|
484
|
+
const results = kept.map((item) => ({
|
|
485
|
+
...utils.attachScore(item.entry, item.score),
|
|
486
|
+
...utils.buildMatchFields(item.passage, snippetTerms)
|
|
487
|
+
}));
|
|
485
488
|
|
|
486
489
|
if (limit) return results.slice(0, limit);
|
|
487
490
|
return results;
|
package/src/rag/search-utils.js
CHANGED
|
@@ -184,11 +184,17 @@ function expandQueryTokens(tokens) {
|
|
|
184
184
|
return [...out];
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// Cap for matched passages returned by search: one chunk (RAG_CHUNK_SIZE 1200)
|
|
188
|
+
// plus its heading path. The same cap applies to semantic and lexical results.
|
|
189
|
+
const PASSAGE_MAX_CHARS = 1500;
|
|
190
|
+
|
|
187
191
|
// A ~maxLen window around the first query term found in text — used to give the
|
|
188
192
|
// agent a matched snippet in search results (issues #146/#149).
|
|
189
193
|
function extractSnippet(text, terms, maxLen = 240) {
|
|
190
194
|
const cleaned = normalizeText(text);
|
|
191
195
|
if (!cleaned) return "";
|
|
196
|
+
// Text that fits is returned whole, so its heading and leading context stay.
|
|
197
|
+
if (cleaned.length <= maxLen) return cleaned;
|
|
192
198
|
const lower = cleaned.toLowerCase();
|
|
193
199
|
let hitAt = -1;
|
|
194
200
|
for (const term of terms) {
|
|
@@ -202,6 +208,50 @@ function extractSnippet(text, terms, maxLen = 240) {
|
|
|
202
208
|
return (start > 0 ? "…" : "") + snippet + (start + maxLen < cleaned.length ? "…" : "");
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
// Function words that match almost any passage; a passage must share a word
|
|
212
|
+
// other than these with the query to count as matched.
|
|
213
|
+
const PASSAGE_STOPWORDS = new Set(
|
|
214
|
+
"a an and are as at be by can do does for from how i if in into is it its me my of on or so the this that to use using via what when where which with without you your".split(" ")
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Each word is kept whole and, when camelCase, also split into its parts
|
|
218
|
+
// ("SelectSource" -> "selectsource", "select", "source"), so API names match
|
|
219
|
+
// plain query words and lowercased identifiers alike.
|
|
220
|
+
function wordTokens(text) {
|
|
221
|
+
const tokens = [];
|
|
222
|
+
for (const word of String(text || "").split(/[^A-Za-z0-9]+/)) {
|
|
223
|
+
if (!word) continue;
|
|
224
|
+
tokens.push(word.toLowerCase());
|
|
225
|
+
const parts = word.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(" ");
|
|
226
|
+
if (parts.length > 1) tokens.push(...parts);
|
|
227
|
+
}
|
|
228
|
+
return tokens;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// True when the passage contains a query content word (compared by stem, with
|
|
232
|
+
// the query's synonym expansion). Semantic scores include the page title,
|
|
233
|
+
// summary, and tags, so the best chunk of a header-driven hit can be an
|
|
234
|
+
// unrelated or near-empty section; such chunks are not shown as passages.
|
|
235
|
+
function passageMatchesQuery(passage, terms) {
|
|
236
|
+
const queryWords = expandQueryTokens(wordTokens(terms.join(" ")).filter((word) => !PASSAGE_STOPWORDS.has(word)));
|
|
237
|
+
if (queryWords.length === 0) return false;
|
|
238
|
+
const wanted = new Set(queryWords.map(stemToken));
|
|
239
|
+
return wordTokens(passage).some((word) => wanted.has(stemToken(word)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Short snippet plus the full matched passage (capped) for one search hit.
|
|
243
|
+
// Both come from the passage only when it contains a query content word;
|
|
244
|
+
// otherwise the snippet comes from `fallbackSnippet` (e.g. the page summary)
|
|
245
|
+
// or is omitted, so an unrelated section is never shown as evidence.
|
|
246
|
+
function buildMatchFields(passage, terms, fallbackSnippet = "") {
|
|
247
|
+
const fields = {};
|
|
248
|
+
const matched = Boolean(passage) && passageMatchesQuery(passage, terms);
|
|
249
|
+
const snippet = extractSnippet(matched ? passage : fallbackSnippet, terms);
|
|
250
|
+
if (snippet) fields.matchedSnippet = snippet;
|
|
251
|
+
if (matched) fields.matchedPassage = extractSnippet(passage, terms, PASSAGE_MAX_CHARS);
|
|
252
|
+
return fields;
|
|
253
|
+
}
|
|
254
|
+
|
|
205
255
|
function buildEntryBaseText(entry) {
|
|
206
256
|
const parts = [entry.title, entry.summary];
|
|
207
257
|
if (Array.isArray(entry.tags) && entry.tags.length > 0) {
|
|
@@ -230,7 +280,10 @@ function buildEmbeddingItems(resourceIndex, ragConfig) {
|
|
|
230
280
|
items.push({
|
|
231
281
|
id: `${entry.id}#${index}`,
|
|
232
282
|
uri: entry.uri,
|
|
233
|
-
text: truncateText(combined, ragConfig.maxTextChars)
|
|
283
|
+
text: truncateText(combined, ragConfig.maxTextChars),
|
|
284
|
+
// The chunk alone (heading path + body), without the page header that
|
|
285
|
+
// is embedded with it; search returns this as the matched passage.
|
|
286
|
+
passage: chunk
|
|
234
287
|
});
|
|
235
288
|
});
|
|
236
289
|
continue;
|
|
@@ -308,6 +361,8 @@ export {
|
|
|
308
361
|
expandQueryTokens,
|
|
309
362
|
stemToken,
|
|
310
363
|
extractSnippet,
|
|
364
|
+
buildMatchFields,
|
|
365
|
+
PASSAGE_MAX_CHARS,
|
|
311
366
|
buildEmbeddingItems,
|
|
312
367
|
buildIndexSignature,
|
|
313
368
|
normalizeVector,
|
|
@@ -72,8 +72,8 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
|
|
|
72
72
|
"This server answers questions about five public Dynamsoft offerings: DBR, DWT, DDV, MRZ, MDS.",
|
|
73
73
|
"",
|
|
74
74
|
"How to use it:",
|
|
75
|
-
"- Call get_index first to see valid product/edition/platform combinations before asking the user anything.",
|
|
76
|
-
"- To answer from documentation: call search,
|
|
75
|
+
"- Call get_index first to see valid product/edition/platform combinations before asking the user anything. Its productSelection.capabilities lists known facts (OCR, VIN, ID documents) that take precedence over inference; for other capabilities, search before saying something is not offered.",
|
|
76
|
+
"- To answer from documentation: call search. When a matched passage in the search response covers the question, answer from it and cite its Source URL. Otherwise call read_document with the exact doc:// uri it returned, base the answer on that text, and cite the public documentation URL it contains. Do not answer from search summaries alone or hand doc:// links to the user.",
|
|
77
77
|
"- For sample:// results, call get_sample_files with resource_uri to get the complete sample project.",
|
|
78
78
|
"- Only ask the user to disambiguate dimensions that actually vary for the chosen product. Do NOT ask about dimensions that don't apply.",
|
|
79
79
|
"",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { formatScoreLabel, formatScoreNote } from "./server-helpers.js";
|
|
2
|
+
import { resolveSearchScope } from "../normalizers.js";
|
|
3
|
+
|
|
4
|
+
// Runs a search in the scope resolveSearchScope picks (see normalizers.js).
|
|
5
|
+
// An edition read from the query never hides every result: with no results
|
|
6
|
+
// it searches again across all editions. `filters` are already normalized.
|
|
7
|
+
async function runScopedSearch(searchResources, { query, product, edition, platform, ...rest }) {
|
|
8
|
+
const scope = resolveSearchScope({ query, product, edition, platform });
|
|
9
|
+
const results = await searchResources({ query, product, edition: scope.edition, platform, ...rest });
|
|
10
|
+
if (!scope.inferredFrom) return { results, scopeNote: "" };
|
|
11
|
+
if (results.length === 0) {
|
|
12
|
+
return { results: await searchResources({ query, product, edition, platform, ...rest }), scopeNote: "" };
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
results,
|
|
16
|
+
scopeNote: `Searched the ${product.toUpperCase()} ${scope.edition} edition because the query names it; pass edition (or platform) explicitly to search another edition.`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Matched passages are inlined for the top doc results, so an agent can answer
|
|
21
|
+
// from the relevant section without a read_document round trip. Each passage
|
|
22
|
+
// is capped by PASSAGE_MAX_CHARS (src/rag/search-utils.js); full pages stay
|
|
23
|
+
// behind read_document. Samples are excluded: their indexed text is generated
|
|
24
|
+
// metadata (name, category, scenario tags), not content.
|
|
25
|
+
const SEARCH_PASSAGE_COUNT = 3;
|
|
26
|
+
|
|
27
|
+
function formatSearchPassages(results, count = SEARCH_PASSAGE_COUNT) {
|
|
28
|
+
const blocks = results
|
|
29
|
+
.slice(0, count)
|
|
30
|
+
.map((entry, index) => ({ entry, rank: index + 1 }))
|
|
31
|
+
.filter(({ entry }) => entry.type === "doc" && entry.matchedPassage)
|
|
32
|
+
.map(({ entry, rank }) => {
|
|
33
|
+
const source = entry.url ? `\nSource: ${entry.url}` : "";
|
|
34
|
+
return `[${rank}] ${entry.title} (${entry.uri})${source}\n${entry.matchedPassage}`;
|
|
35
|
+
});
|
|
36
|
+
if (blocks.length === 0) return "";
|
|
37
|
+
return [
|
|
38
|
+
`Matched passages from the top ${count} results (call read_document for the full page):`,
|
|
39
|
+
...blocks
|
|
40
|
+
].join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A resource_link description that lets an agent choose WITHOUT a follow-up
|
|
44
|
+
// read: type | scope | version | score - summary, then the matched snippet and
|
|
45
|
+
// canonical URL when available (issue #146).
|
|
46
|
+
function createHitDescriber({ formatScopeLabel, getSampleIdFromUri }) {
|
|
47
|
+
return function describeHit(entry) {
|
|
48
|
+
const versionLabel = entry.version ? `v${entry.version}` : "n/a";
|
|
49
|
+
const scopeLabel = formatScopeLabel(entry);
|
|
50
|
+
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
51
|
+
const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
|
|
52
|
+
const scoreLabel = formatScoreLabel(entry);
|
|
53
|
+
const sharedLabel = entry.type === "doc" && Array.isArray(entry.tags) && entry.tags.includes("shared")
|
|
54
|
+
? ` | shared Capture Vision API used by DBR (DCV v${entry.sourceVersion || "n/a"})`
|
|
55
|
+
: "";
|
|
56
|
+
let desc = `${entry.type.toUpperCase()} | ${scopeLabel} | ${versionLabel}${sharedLabel}${scoreLabel} - ${entry.summary}${sampleHint}`;
|
|
57
|
+
if (entry.matchedSnippet) {
|
|
58
|
+
const snippet = String(entry.matchedSnippet).replace(/\s+/g, " ").slice(0, 220);
|
|
59
|
+
desc += ` — "${snippet}"`;
|
|
60
|
+
}
|
|
61
|
+
if (entry.type === "doc" && entry.url) {
|
|
62
|
+
desc += ` | ${entry.url}`;
|
|
63
|
+
}
|
|
64
|
+
return desc;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The search tool's response for ranked results: lead text, one resource_link
|
|
69
|
+
// per hit, matched passages (unless contentMode is "none"), plain URI list.
|
|
70
|
+
// `product` is the normalized product filter (empty when none was given);
|
|
71
|
+
// `scopeNote` explains a search scope the server chose (empty when none).
|
|
72
|
+
function buildSearchResultsResponse({ query, product, scopeNote = "", results, contentMode, describeHit, getSampleIdFromUri }) {
|
|
73
|
+
const passages = contentMode === "none" ? "" : formatSearchPassages(results);
|
|
74
|
+
const linkGuidance = "call read_document with the exact uri of a doc:// result for the full page, or get_sample_files with resource_uri for a sample:// result.";
|
|
75
|
+
const readGuidance = passages
|
|
76
|
+
? `Matched passages of the top results follow the links; when they do not cover the question, ${linkGuidance}`
|
|
77
|
+
: `To read a result, ${linkGuidance}`;
|
|
78
|
+
const found = `Found ${results.length} result(s) for "${query}".${scopeNote ? ` ${scopeNote}` : ""} ${readGuidance}`;
|
|
79
|
+
// Capture Vision / DCV is the umbrella framework, not a selectable public
|
|
80
|
+
// product; steer the agent to the right offering (issue #150).
|
|
81
|
+
const dcvUmbrella = /\b(capture[\s-]?vision|dcv)\b/i.test(query) && !product;
|
|
82
|
+
const leadText = dcvUmbrella
|
|
83
|
+
? `"Capture Vision" (DCV) is Dynamsoft's umbrella framework — pick the public product for your task: DBR (barcodes), MRZ (passports/IDs), MDS (document capture), DWT (scanner hardware), DDV (document viewer). Results below span the closest matches.\n${found}`
|
|
84
|
+
: found;
|
|
85
|
+
|
|
86
|
+
const content = [{ type: "text", text: leadText }];
|
|
87
|
+
for (const entry of results) {
|
|
88
|
+
content.push({
|
|
89
|
+
type: "resource_link",
|
|
90
|
+
uri: entry.uri,
|
|
91
|
+
name: entry.title,
|
|
92
|
+
description: describeHit(entry),
|
|
93
|
+
mimeType: entry.mimeType,
|
|
94
|
+
annotations: {
|
|
95
|
+
audience: ["assistant"],
|
|
96
|
+
priority: 0.8
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (passages) {
|
|
101
|
+
content.push({ type: "text", text: passages });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const plainLines = results.map((entry, index) => {
|
|
105
|
+
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
106
|
+
const action = entry.type === "sample" ? "get_sample_files resource_uri" : "read_document uri";
|
|
107
|
+
const sampleNote = sampleId ? ` sample_id=${sampleId}` : "";
|
|
108
|
+
const scoreNote = formatScoreNote(entry);
|
|
109
|
+
return `- ${index + 1}. ${entry.uri}${sampleNote}${scoreNote} (${action})`;
|
|
110
|
+
});
|
|
111
|
+
content.push({
|
|
112
|
+
type: "text",
|
|
113
|
+
text: ["Plain URIs (copy/paste):", ...plainLines].join("\n")
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { content };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Everything an agent reads from a search response: text items plus each
|
|
120
|
+
// link's uri, name, and description.
|
|
121
|
+
function searchResponseText(response) {
|
|
122
|
+
return response.content
|
|
123
|
+
.map((item) => (item.type === "resource_link"
|
|
124
|
+
? [item.uri, item.name, item.description].filter(Boolean).join("\n")
|
|
125
|
+
: item.text || ""))
|
|
126
|
+
.join("\n\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
SEARCH_PASSAGE_COUNT,
|
|
131
|
+
formatSearchPassages,
|
|
132
|
+
createHitDescriber,
|
|
133
|
+
buildSearchResultsResponse,
|
|
134
|
+
searchResponseText,
|
|
135
|
+
runScopedSearch
|
|
136
|
+
};
|
|
@@ -402,6 +402,36 @@ function inferProductFromQuery(query) {
|
|
|
402
402
|
return "";
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
// Edition hints for DBR searches, checked against the current query only.
|
|
406
|
+
// React Native and Node.js (also written "node js", "node-js") are removed
|
|
407
|
+
// before the web check so their "js"/"react" do not also count as web.
|
|
408
|
+
const NON_WEB_JS = /react[\s-]?native|node[\s.-]?js/g;
|
|
409
|
+
const QUERY_EDITION_PATTERNS = {
|
|
410
|
+
mobile: /\b(?:android|ios|iphone|ipad|swift|kotlin|flutter|maui|xamarin|react[\s-]?native|mobile)\b/,
|
|
411
|
+
server: /\b(?:python|java|cpp|csharp|dotnet|node[\s.-]?js|server|desktop)\b|(?<![a-z0-9])(?:c\+\+|c#|\.net)(?![a-z0-9])/,
|
|
412
|
+
web: /\b(?:web|browser|javascript|typescript|js|react|angular|vue|svelte|nuxt|next\.?js|html)\b/
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// The edition a search runs in when the caller gave neither edition nor
|
|
416
|
+
// platform. MRZ and MDS are served for web only, so they default to web.
|
|
417
|
+
// For DBR, an edition is taken from the query only when it names exactly one
|
|
418
|
+
// edition unambiguously ("web app", "Android"); otherwise all editions are
|
|
419
|
+
// searched. `inferredFrom` is set when the query decided the edition, so the
|
|
420
|
+
// response can say so.
|
|
421
|
+
function resolveSearchScope({ query, product, edition, platform }) {
|
|
422
|
+
if (edition || platform) return { edition: edition || "", inferredFrom: "" };
|
|
423
|
+
if (product === "mrz" || product === "mds") return { edition: "web", inferredFrom: "" };
|
|
424
|
+
if (product !== "dbr") return { edition: "", inferredFrom: "" };
|
|
425
|
+
const text = String(query || "").toLowerCase();
|
|
426
|
+
const mentioned = [];
|
|
427
|
+
if (QUERY_EDITION_PATTERNS.mobile.test(text)) mentioned.push("mobile");
|
|
428
|
+
if (QUERY_EDITION_PATTERNS.server.test(text)) mentioned.push("server");
|
|
429
|
+
const withoutOtherJs = text.replace(NON_WEB_JS, " ");
|
|
430
|
+
if (QUERY_EDITION_PATTERNS.web.test(withoutOtherJs)) mentioned.push("web");
|
|
431
|
+
if (mentioned.length !== 1) return { edition: "", inferredFrom: "" };
|
|
432
|
+
return { edition: mentioned[0], inferredFrom: "query" };
|
|
433
|
+
}
|
|
434
|
+
|
|
405
435
|
export {
|
|
406
436
|
sdkAliases,
|
|
407
437
|
platformAliases,
|
|
@@ -421,6 +451,7 @@ export {
|
|
|
421
451
|
isWebFrameworkPlatform,
|
|
422
452
|
isWebPlatform,
|
|
423
453
|
inferProductFromQuery,
|
|
454
|
+
resolveSearchScope,
|
|
424
455
|
KNOWN_PLATFORMS,
|
|
425
456
|
KNOWN_EDITIONS,
|
|
426
457
|
validatePlatform,
|
|
@@ -43,6 +43,16 @@ const DBR_ONLY_EDITIONS_NOTE = `Only DBR has multiple editions; ${WEB_ONLY_OMIT_
|
|
|
43
43
|
const API_LEVEL_NOTE = "high-level or low-level. DBR mobile ONLY; ignored for web/server/other products.";
|
|
44
44
|
const PRODUCT_SELECTION_GUIDANCE = "DBR is the only product with multiple editions (mobile/web/server). DWT, DDV, MRZ, and MDS are web/JavaScript-only here, so do not ask the user which platform or language for them. api_level (high-level/low-level) applies only to DBR mobile.";
|
|
45
45
|
|
|
46
|
+
// Capability facts confirmed by Dynamsoft that the docs do not state outright
|
|
47
|
+
// (especially what is NOT offered). Served by get_index and the pinned
|
|
48
|
+
// product-selection guidance so agents answer "do you support X?" correctly.
|
|
49
|
+
const CAPABILITY_FACTS = {
|
|
50
|
+
note: "Known capability facts. They take precedence over inference; for anything not listed here, search before concluding that a capability is unsupported.",
|
|
51
|
+
ocr: "There is no standalone OCR SDK. The OCR Connector is an add-on of Dynamic Web TWAIN (DWT) and is used only together with DWT, typically on a PC or workstation. Do not recommend it for mobile or non-DWT workflows.",
|
|
52
|
+
vin: "VINs are read from their barcodes with DBR (web sample: read-vin). Reading VIN text (OCR) is not offered.",
|
|
53
|
+
idDocuments: "Passports and ID cards with an MRZ zone: MRZ. AAMVA driver licenses: DBR, which decodes the PDF417 barcode and parses the driver-license fields (web sample: read-a-drivers-license)."
|
|
54
|
+
};
|
|
55
|
+
|
|
46
56
|
function normalizePublicOffering(product) {
|
|
47
57
|
if (!product) return "";
|
|
48
58
|
const normalized = product.trim().toLowerCase();
|
|
@@ -76,6 +86,7 @@ export {
|
|
|
76
86
|
DBR_ONLY_EDITIONS_NOTE,
|
|
77
87
|
API_LEVEL_NOTE,
|
|
78
88
|
PRODUCT_SELECTION_GUIDANCE,
|
|
89
|
+
CAPABILITY_FACTS,
|
|
79
90
|
normalizePublicOffering,
|
|
80
91
|
getHydrationProduct,
|
|
81
92
|
isKnownPublicOffering,
|
|
@@ -2,7 +2,7 @@ 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
4
|
import { shortDocPath } from "./docs-loader.js";
|
|
5
|
-
import { PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
|
|
5
|
+
import { CAPABILITY_FACTS, PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
|
|
6
6
|
|
|
7
7
|
const MRZ_MATCHER = /(?:\bmrz\b|machine[-\s]?readable[-\s]?zone|passport)/i;
|
|
8
8
|
const MDS_MATCHER = /(?:document[-\s]scan|document scanner|document scanning|document normalizer|document normalization|normaliz|auto[-\s]?capture|crop|cropping|deskew)/i;
|
|
@@ -93,7 +93,15 @@ function buildProductSelectionGuidanceText() {
|
|
|
93
93
|
"- MRZ Scanner (MRZ): use for passport and machine-readable-zone workflows. Public guidance is web/mobile solution/RTU only. Do not default to a foundational API path here; handle server/desktop separately through contact-driven guidance.",
|
|
94
94
|
"- Mobile Document Scanner (MDS): use for document scan and normalization workflows. Public guidance is web-only solution/RTU. Do not default to a foundational API path here; handle mobile and server/desktop separately through contact-driven guidance.",
|
|
95
95
|
"",
|
|
96
|
-
"Choose DBR when you want direct barcode capabilities, MRZ when you want passport/MRZ flows, MDS when you want document scanning flows, DWT when you need browser acquisition, and DDV when you need viewing, annotation, multi-page handling, or PDF-oriented extension paths."
|
|
96
|
+
"Choose DBR when you want direct barcode capabilities, MRZ when you want passport/MRZ flows, MDS when you want document scanning flows, DWT when you need browser acquisition, and DDV when you need viewing, annotation, multi-page handling, or PDF-oriented extension paths.",
|
|
97
|
+
"",
|
|
98
|
+
"## Known Capability Facts",
|
|
99
|
+
"",
|
|
100
|
+
CAPABILITY_FACTS.note,
|
|
101
|
+
"",
|
|
102
|
+
`- OCR: ${CAPABILITY_FACTS.ocr}`,
|
|
103
|
+
`- VIN: ${CAPABILITY_FACTS.vin}`,
|
|
104
|
+
`- ID documents: ${CAPABILITY_FACTS.idDocuments}`
|
|
97
105
|
].join("\n");
|
|
98
106
|
}
|
|
99
107
|
|
|
@@ -428,6 +436,7 @@ function buildIndexData({
|
|
|
428
436
|
productSelection: {
|
|
429
437
|
guidance: PRODUCT_SELECTION_GUIDANCE,
|
|
430
438
|
publicOfferings: [...PUBLIC_OFFERING_PRODUCTS],
|
|
439
|
+
capabilities: { ...CAPABILITY_FACTS },
|
|
431
440
|
offerings: {
|
|
432
441
|
dwt: {
|
|
433
442
|
name: "Dynamic Web TWAIN",
|
|
@@ -506,6 +515,7 @@ function buildIndexData({
|
|
|
506
515
|
productSelection: {
|
|
507
516
|
guidance: PRODUCT_SELECTION_GUIDANCE,
|
|
508
517
|
publicOfferings: [...PUBLIC_OFFERING_PRODUCTS],
|
|
518
|
+
capabilities: { ...CAPABILITY_FACTS },
|
|
509
519
|
dcvSupersetSummary: "Dynamsoft Capture Vision (DCV) aggregates Dynamsoft Barcode Reader (DBR), Dynamsoft Label Recognizer (DLR), Dynamsoft Document Normalizer (DDN), Dynamsoft Code Parser (DCP), and Dynamsoft Camera Enhancer (DCE) into one pipeline. DCV is internal only and is not a selectable public product.",
|
|
510
520
|
useDbrWhen: [
|
|
511
521
|
"Barcode-only workflows where DCV-specific workflows are not required."
|
|
@@ -13,10 +13,11 @@ export function registerDocumentTools({ server, readIndexedResource }) {
|
|
|
13
13
|
"Read the full text of a Dynamsoft documentation page by its doc:// URI.",
|
|
14
14
|
"",
|
|
15
15
|
"WHEN TO USE:",
|
|
16
|
-
"- After search returns doc:// results: call read_document with the exact uri from a resource_link or the plain URI list before answering.",
|
|
17
|
-
"- When you need the
|
|
16
|
+
"- After search returns doc:// results whose matched passages do not cover the question: call read_document with the exact uri from a resource_link or the plain URI list before answering.",
|
|
17
|
+
"- When you need the full article (complete API details, parameter lists, code with its original formatting) rather than a matched passage or summary.",
|
|
18
18
|
"",
|
|
19
19
|
"WHEN NOT TO USE:",
|
|
20
|
+
"- When a matched passage returned by search already answers the question; cite that result's public URL instead.",
|
|
20
21
|
"- For sample:// URIs, use get_sample_files with resource_uri instead (complete sample projects).",
|
|
21
22
|
"- Do not construct or edit doc:// URIs yourself; they are generated by search and may change across data refreshes.",
|
|
22
23
|
"",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { SEARCH_PASSAGE_COUNT, buildSearchResultsResponse, createHitDescriber, runScopedSearch } from "../helpers/search-response.js";
|
|
2
3
|
import { formatScoreLabel, formatScoreNote } from "../helpers/server-helpers.js";
|
|
3
4
|
import {
|
|
4
5
|
buildUnknownPublicProductResponse,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
WEB_ONLY_OMIT_NOTE
|
|
9
10
|
} from "../public-offerings.js";
|
|
10
11
|
import { buildUnsupportedPublicScopeResponse } from "./public-routing.js";
|
|
11
|
-
import { validatePlatform, validateEdition } from "../normalizers.js";
|
|
12
|
+
import { resolveSearchScope, validatePlatform, validateEdition } from "../normalizers.js";
|
|
12
13
|
|
|
13
14
|
export function registerIndexTools({
|
|
14
15
|
server,
|
|
@@ -25,28 +26,7 @@ export function registerIndexTools({
|
|
|
25
26
|
getSampleEntries,
|
|
26
27
|
getSampleSuggestions
|
|
27
28
|
}) {
|
|
28
|
-
|
|
29
|
-
// follow-up read: type | scope | version | score - summary, then the matched
|
|
30
|
-
// snippet and canonical URL when available (issue #146).
|
|
31
|
-
function buildHitDescription(entry) {
|
|
32
|
-
const versionLabel = entry.version ? `v${entry.version}` : "n/a";
|
|
33
|
-
const scopeLabel = formatScopeLabel(entry);
|
|
34
|
-
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
35
|
-
const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
|
|
36
|
-
const scoreLabel = formatScoreLabel(entry);
|
|
37
|
-
const sharedLabel = entry.type === "doc" && Array.isArray(entry.tags) && entry.tags.includes("shared")
|
|
38
|
-
? ` | shared Capture Vision API used by DBR (DCV v${entry.sourceVersion || "n/a"})`
|
|
39
|
-
: "";
|
|
40
|
-
let desc = `${entry.type.toUpperCase()} | ${scopeLabel} | ${versionLabel}${sharedLabel}${scoreLabel} - ${entry.summary}${sampleHint}`;
|
|
41
|
-
if (entry.matchedSnippet) {
|
|
42
|
-
const snippet = String(entry.matchedSnippet).replace(/\s+/g, " ").slice(0, 220);
|
|
43
|
-
desc += ` — "${snippet}"`;
|
|
44
|
-
}
|
|
45
|
-
if (entry.type === "doc" && entry.url) {
|
|
46
|
-
desc += ` | ${entry.url}`;
|
|
47
|
-
}
|
|
48
|
-
return desc;
|
|
49
|
-
}
|
|
29
|
+
const buildHitDescription = createHitDescriber({ formatScopeLabel, getSampleIdFromUri });
|
|
50
30
|
|
|
51
31
|
function looksLikeSampleIdQuery(value) {
|
|
52
32
|
const normalized = String(value || "").trim();
|
|
@@ -119,8 +99,9 @@ export function registerIndexTools({
|
|
|
119
99
|
"- version: Version constraint (e.g. '10', '11.x'). Only latest major is served by default.",
|
|
120
100
|
"- type: 'doc', 'sample', 'index', 'policy', or 'any' (default). Use 'sample' to restrict to sample results.",
|
|
121
101
|
"- limit: 1-10 (default 5). Max number of results.",
|
|
102
|
+
`- content: 'passage' (default) includes the matched passage (up to ~1500 chars) of the top ${SEARCH_PASSAGE_COUNT} results; 'none' returns links and short snippets only.`,
|
|
122
103
|
"",
|
|
123
|
-
"RETURNS: An MCP response whose content array includes a leading text summary item
|
|
104
|
+
"RETURNS: An MCP response whose content array includes a leading text summary item, zero or more resource_link items with URIs, and (unless content='none') a text item with the matched passages of the top results. Answer from a passage when it covers the question; otherwise, for doc:// URIs call read_document with the exact returned uri to get the full document text. For sample:// URIs, call get_sample_files with resource_uri to get the full project files.",
|
|
124
105
|
"",
|
|
125
106
|
"RELATED TOOLS: get_index (discover products first), list_samples (browse all samples), read_document (read a doc:// result), get_sample_files (retrieve full sample project files)."
|
|
126
107
|
].join("\n"),
|
|
@@ -131,7 +112,8 @@ export function registerIndexTools({
|
|
|
131
112
|
platform: z.string().optional().describe(`Platform (DBR only spans multiple): android, ios, maui, react-native, flutter, js, python, cpp, java, dotnet, nodejs, angular, blazor, capacitor, electron, es6, native-ts, next, nuxt, pwa, react, requirejs, svelte, vue, webview, spm, core. ${WEB_ONLY_OMIT_NOTE}`),
|
|
132
113
|
version: z.string().optional().describe("Version constraint (major or full version)"),
|
|
133
114
|
type: z.enum(["doc", "sample", "index", "policy", "any"]).optional(),
|
|
134
|
-
limit: z.number().int().min(1).max(10).optional().describe("Max results (default 5)")
|
|
115
|
+
limit: z.number().int().min(1).max(10).optional().describe("Max results (default 5)"),
|
|
116
|
+
content: z.enum(["passage", "none"]).optional().describe(`'passage' (default): include the matched passage of the top ${SEARCH_PASSAGE_COUNT} results. 'none': links and short snippets only.`)
|
|
135
117
|
},
|
|
136
118
|
annotations: {
|
|
137
119
|
readOnlyHint: true,
|
|
@@ -140,7 +122,7 @@ export function registerIndexTools({
|
|
|
140
122
|
openWorldHint: false
|
|
141
123
|
}
|
|
142
124
|
},
|
|
143
|
-
async ({ query, product, edition, platform, version, type, limit }) => {
|
|
125
|
+
async ({ query, product, edition, platform, version, type, limit, content: contentMode }) => {
|
|
144
126
|
const normalizedProduct = normalizeProduct(product);
|
|
145
127
|
if (product && !isKnownPublicOffering(normalizedProduct)) {
|
|
146
128
|
return buildUnknownPublicProductResponse(product);
|
|
@@ -162,9 +144,21 @@ export function registerIndexTools({
|
|
|
162
144
|
const unsupportedScopeResponse = buildUnsupportedPublicScopeResponse(normalizedProduct, normalizedEdition, normalizedPlatform);
|
|
163
145
|
if (unsupportedScopeResponse) return unsupportedScopeResponse;
|
|
164
146
|
|
|
165
|
-
|
|
147
|
+
// MRZ/MDS are served for web only: hydration, exact sample matches,
|
|
148
|
+
// sample suggestions, and ranked search all use the web default (an
|
|
149
|
+
// unfiltered MRZ scope would otherwise download the mobile repos in lazy
|
|
150
|
+
// mode). DBR editions are read from the query only for ranked search.
|
|
151
|
+
// Version policy keeps the scope exactly as the caller gave it.
|
|
152
|
+
const defaultEdition = resolveSearchScope({
|
|
153
|
+
query: "",
|
|
166
154
|
product: normalizedProduct,
|
|
167
155
|
edition: normalizedEdition,
|
|
156
|
+
platform: normalizedPlatform
|
|
157
|
+
}).edition;
|
|
158
|
+
|
|
159
|
+
await ensureScopeHydrated({
|
|
160
|
+
product: normalizedProduct,
|
|
161
|
+
edition: defaultEdition,
|
|
168
162
|
platform: normalizedPlatform,
|
|
169
163
|
type: type || "any"
|
|
170
164
|
});
|
|
@@ -182,14 +176,13 @@ export function registerIndexTools({
|
|
|
182
176
|
}
|
|
183
177
|
|
|
184
178
|
const maxResults = Math.min(limit || 5, 10);
|
|
185
|
-
|
|
186
179
|
// Exact sample-ID fast path (when searching for samples)
|
|
187
180
|
const effectiveType = type || "any";
|
|
188
181
|
if (effectiveType === "sample" || effectiveType === "any") {
|
|
189
182
|
const normalizedQuery = normalizeSampleName(query);
|
|
190
183
|
const scopedSamples = getSampleEntries({
|
|
191
184
|
product: normalizedProduct,
|
|
192
|
-
edition:
|
|
185
|
+
edition: defaultEdition,
|
|
193
186
|
platform: normalizedPlatform
|
|
194
187
|
});
|
|
195
188
|
const exactMatches = scopedSamples.filter((entry) => {
|
|
@@ -243,9 +236,9 @@ export function registerIndexTools({
|
|
|
243
236
|
const preferSampleSuggestionFallback =
|
|
244
237
|
effectiveType === "sample" && looksLikeSampleIdQuery(query);
|
|
245
238
|
|
|
246
|
-
const topResults = preferSampleSuggestionFallback
|
|
247
|
-
? []
|
|
248
|
-
: await searchResources
|
|
239
|
+
const { results: topResults, scopeNote } = preferSampleSuggestionFallback
|
|
240
|
+
? { results: [], scopeNote: "" }
|
|
241
|
+
: await runScopedSearch(searchResources, {
|
|
249
242
|
query,
|
|
250
243
|
product: normalizedProduct,
|
|
251
244
|
edition: normalizedEdition,
|
|
@@ -260,7 +253,7 @@ export function registerIndexTools({
|
|
|
260
253
|
const suggestions = await getSampleSuggestions({
|
|
261
254
|
query,
|
|
262
255
|
product: normalizedProduct,
|
|
263
|
-
edition:
|
|
256
|
+
edition: defaultEdition,
|
|
264
257
|
platform: normalizedPlatform,
|
|
265
258
|
limit: maxResults
|
|
266
259
|
});
|
|
@@ -315,52 +308,15 @@ export function registerIndexTools({
|
|
|
315
308
|
};
|
|
316
309
|
}
|
|
317
310
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
:
|
|
325
|
-
|
|
326
|
-
{
|
|
327
|
-
type: "text",
|
|
328
|
-
text: leadText
|
|
329
|
-
}
|
|
330
|
-
];
|
|
331
|
-
|
|
332
|
-
for (const entry of topResults) {
|
|
333
|
-
const versionLabel = entry.version ? `v${entry.version}` : "n/a";
|
|
334
|
-
const scopeLabel = formatScopeLabel(entry);
|
|
335
|
-
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
336
|
-
const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
|
|
337
|
-
const scoreLabel = formatScoreLabel(entry);
|
|
338
|
-
content.push({
|
|
339
|
-
type: "resource_link",
|
|
340
|
-
uri: entry.uri,
|
|
341
|
-
name: entry.title,
|
|
342
|
-
description: buildHitDescription(entry),
|
|
343
|
-
mimeType: entry.mimeType,
|
|
344
|
-
annotations: {
|
|
345
|
-
audience: ["assistant"],
|
|
346
|
-
priority: 0.8
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const plainLines = topResults.map((entry, index) => {
|
|
352
|
-
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
353
|
-
const action = entry.type === "sample" ? "get_sample_files resource_uri" : "read_document uri";
|
|
354
|
-
const sampleNote = sampleId ? ` sample_id=${sampleId}` : "";
|
|
355
|
-
const scoreNote = formatScoreNote(entry);
|
|
356
|
-
return `- ${index + 1}. ${entry.uri}${sampleNote}${scoreNote} (${action})`;
|
|
357
|
-
});
|
|
358
|
-
content.push({
|
|
359
|
-
type: "text",
|
|
360
|
-
text: ["Plain URIs (copy/paste):", ...plainLines].join("\n")
|
|
311
|
+
return buildSearchResultsResponse({
|
|
312
|
+
query,
|
|
313
|
+
results: topResults,
|
|
314
|
+
contentMode,
|
|
315
|
+
product: normalizedProduct,
|
|
316
|
+
scopeNote,
|
|
317
|
+
describeHit: buildHitDescription,
|
|
318
|
+
getSampleIdFromUri
|
|
361
319
|
});
|
|
362
|
-
|
|
363
|
-
return { content };
|
|
364
320
|
}
|
|
365
321
|
);
|
|
366
322
|
}
|