crawlforge-mcp-server 6.2.0 → 6.3.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/package.json +1 -1
- package/server.js +1 -1
- package/src/cli/commands/login.js +1 -1
- package/src/core/AgentOrchestrator.js +106 -1
- package/src/core/ResearchOrchestrator.js +7 -1
- package/src/core/analysis/ContentAnalyzer.js +69 -13
- package/src/core/llm/LLMManager.js +94 -66
- package/src/schemas/toolOutputSchemas.js +1 -1
- package/src/tools/extract/extractStructured.js +28 -11
- package/src/tools/extract/extractWithLlm.js +1 -62
- package/src/tools/research/deepResearch.js +3 -2
- package/src/utils/schemaValidate.js +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.1",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
107
107
|
// Create the server
|
|
108
108
|
const server = new McpServer({
|
|
109
109
|
name: "crawlforge",
|
|
110
|
-
version: "6.
|
|
110
|
+
version: "6.3.1",
|
|
111
111
|
description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
112
112
|
homepage: "https://www.crawlforge.dev",
|
|
113
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { randomBytes, createHash } from 'node:crypto';
|
|
10
10
|
import { hostname } from 'node:os';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
-
import authManager from '../../core/
|
|
12
|
+
import authManager from '../../core/AuthManager.js';
|
|
13
13
|
import { resolveApiEndpoint } from '../../core/endpointGuard.js';
|
|
14
14
|
|
|
15
15
|
const POLL_INTERVAL_MS = 3000;
|
|
@@ -100,6 +100,62 @@ export function unverifiedValues(answer, sourceText) {
|
|
|
100
100
|
*/
|
|
101
101
|
const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
102
102
|
|
|
103
|
+
/** Version numbers as they appear in an answer, e.g. "2.11.4". */
|
|
104
|
+
const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
|
|
105
|
+
|
|
106
|
+
/** Hosts that publish user discussion rather than a project's own statements. */
|
|
107
|
+
const DISCUSSION_HOSTS = new Set([
|
|
108
|
+
'news.ycombinator.com', 'reddit.com', 'www.reddit.com', 'quora.com', 'www.quora.com',
|
|
109
|
+
'stackoverflow.com', 'serverfault.com', 'superuser.com', 'askubuntu.com',
|
|
110
|
+
'facebook.com', 'www.facebook.com', 'twitter.com', 'x.com'
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when a URL is a discussion page — a forum, Q&A site or social post.
|
|
115
|
+
* Covers the hosted-forum conventions too: caddy.community, community.acme.io,
|
|
116
|
+
* forum.example.org, discuss.example.org, anything.stackexchange.com.
|
|
117
|
+
*/
|
|
118
|
+
export function isDiscussionSource(url) {
|
|
119
|
+
try {
|
|
120
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
121
|
+
return DISCUSSION_HOSTS.has(host)
|
|
122
|
+
|| /(^|\.)(community|forum|forums|discuss|discourse)\./.test(host)
|
|
123
|
+
|| /\.(community|forum)$/.test(host)
|
|
124
|
+
|| /\.stackexchange\.com$/.test(host);
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when the task asks for a version or release number. */
|
|
131
|
+
export function isVersionQuestion(prompt) {
|
|
132
|
+
return /\b(version|versions|release|releases|released|changelog)\b/i.test(prompt || '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Version numbers the answer states that no non-discussion source supports.
|
|
137
|
+
*
|
|
138
|
+
* A forum thread carries real version strings, so the literal provenance check
|
|
139
|
+
* passes them: asked for the current Caddy release, the agent answered "2.4.3"
|
|
140
|
+
* off a caddy.community thread about a different project, while caddyserver.com
|
|
141
|
+
* and the GitHub repo — both fetched — stated no version at all (R19). A
|
|
142
|
+
* version is a claim about the project, and a discussion page is not the
|
|
143
|
+
* project speaking.
|
|
144
|
+
*
|
|
145
|
+
* Returns [] when nothing but discussion pages was fetched: there is then no
|
|
146
|
+
* better source to have preferred, and the provenance check already applies.
|
|
147
|
+
*/
|
|
148
|
+
export function unsupportedVersionClaims(answer, evidence) {
|
|
149
|
+
if (!answer) return [];
|
|
150
|
+
// URLs are citations, not claims — a version inside one is not an assertion.
|
|
151
|
+
const claims = [...new Set(answer.replace(/https?:\/\/\S+/g, ' ').match(VERSION_IN_TEXT) || [])];
|
|
152
|
+
if (claims.length === 0) return [];
|
|
153
|
+
const authoritative = (evidence || []).filter(e => !isDiscussionSource(e.url));
|
|
154
|
+
if (authoritative.length === 0) return [];
|
|
155
|
+
const supported = authoritative.map(e => e.text || '').join('\n');
|
|
156
|
+
return claims.filter(version => !supported.includes(version));
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
export function isCurrentStateTask(prompt) {
|
|
104
160
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
105
161
|
}
|
|
@@ -459,6 +515,10 @@ export class AgentOrchestrator {
|
|
|
459
515
|
let degraded = false;
|
|
460
516
|
let degradedReason;
|
|
461
517
|
let unverified = [];
|
|
518
|
+
let forumOnlyVersions = [];
|
|
519
|
+
// Distinct from `degraded`: a run can be degraded because its version
|
|
520
|
+
// claims are unsupported and still have had its provenance checked.
|
|
521
|
+
let synthesisFailed = false;
|
|
462
522
|
|
|
463
523
|
try {
|
|
464
524
|
// Wording matters for small local models (llama3.2-class): without the
|
|
@@ -515,8 +575,49 @@ export class AgentOrchestrator {
|
|
|
515
575
|
`\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
|
|
516
576
|
`${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
|
|
517
577
|
}
|
|
578
|
+
|
|
579
|
+
// Asked for a current version, the agent answered "2.4.3" from a forum
|
|
580
|
+
// thread about a different project while the project's own site and its
|
|
581
|
+
// GitHub repo — both fetched — stated no version at all (R19). The
|
|
582
|
+
// literal provenance check cannot catch this: the string really is on a
|
|
583
|
+
// fetched page. A version only a discussion page supports gets the same
|
|
584
|
+
// one corrective rewrite, and what survives it is flagged rather than
|
|
585
|
+
// presented as the answer.
|
|
586
|
+
if (currentState && isVersionQuestion(prompt)) {
|
|
587
|
+
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
588
|
+
if (forumOnlyVersions.length > 0) {
|
|
589
|
+
const versionRetryPrompt =
|
|
590
|
+
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
591
|
+
`It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
|
|
592
|
+
`only in forum or discussion pages, not in the project's own pages among the sources. ` +
|
|
593
|
+
`A forum post about a different project is not a release announcement. ` +
|
|
594
|
+
`Rewrite the answer using only versions stated by the project's own sources. ` +
|
|
595
|
+
`If none of them states a current version, say plainly that the fetched sources do not state one.`;
|
|
596
|
+
try {
|
|
597
|
+
const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
|
|
598
|
+
if (rewritten && rewritten.trim()) {
|
|
599
|
+
answer = rewritten;
|
|
600
|
+
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
601
|
+
}
|
|
602
|
+
} catch {
|
|
603
|
+
// Keep the previous answer; it is flagged below.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (forumOnlyVersions.length > 0) {
|
|
607
|
+
answer +=
|
|
608
|
+
`\n\nSource warning: ${forumOnlyVersions.map(v => `"${v}"`).join(', ')} ` +
|
|
609
|
+
`${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} only in discussion pages among the fetched sources, ` +
|
|
610
|
+
`not in the project's own. Treat ${forumOnlyVersions.length === 1 ? 'it' : 'them'} as unconfirmed.`;
|
|
611
|
+
degraded = true;
|
|
612
|
+
degradedReason =
|
|
613
|
+
`Version claim unsupported by the project's own sources: ` +
|
|
614
|
+
`${forumOnlyVersions.map(v => `"${v}"`).join(', ')} ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
|
|
615
|
+
`only in discussion pages. The authoritative pages fetched state no version.`;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
518
618
|
} catch (err) {
|
|
519
619
|
degraded = true;
|
|
620
|
+
synthesisFailed = true;
|
|
520
621
|
degradedReason = `LLM synthesis unavailable: ${err.message}`;
|
|
521
622
|
// Return raw evidence so the host LLM can synthesize
|
|
522
623
|
answer = null;
|
|
@@ -531,7 +632,11 @@ export class AgentOrchestrator {
|
|
|
531
632
|
reason: degradedReason,
|
|
532
633
|
steps: step,
|
|
533
634
|
urls_fetched: urlsFetched,
|
|
534
|
-
provenance: {
|
|
635
|
+
provenance: {
|
|
636
|
+
checked: !synthesisFailed,
|
|
637
|
+
unverified,
|
|
638
|
+
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
|
|
639
|
+
}
|
|
535
640
|
};
|
|
536
641
|
}
|
|
537
642
|
|
|
@@ -2000,7 +2000,13 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
2000
2000
|
sessionId: this.researchState.sessionId,
|
|
2001
2001
|
topic,
|
|
2002
2002
|
synthesisMode: 'raw_evidence',
|
|
2003
|
-
|
|
2003
|
+
// Raw evidence has two quite different causes and the note used to
|
|
2004
|
+
// report only one of them: a run that synthesized fine until its
|
|
2005
|
+
// token budget ran out was still told to set an API key it already
|
|
2006
|
+
// had (R19). Say which one actually happened.
|
|
2007
|
+
note: this.researchState.tokenBudgetExceeded
|
|
2008
|
+
? `This response contains raw research evidence with no AI synthesis. Synthesis ran until the research token budget (${this.researchState.tokenBudgetChars.toLocaleString()} chars of source content) was exhausted, then stopped for the rest of the session. The calling LLM (you) should synthesize these sources. To get internal synthesis, narrow the topic or lower maxUrls, or raise RESEARCH_TOKEN_BUDGET_CHARS in the MCP server environment.`
|
|
2009
|
+
: "This response contains raw research evidence with no AI synthesis. The calling LLM (you) should synthesize these sources to answer the user's question. To enable internal LLM synthesis instead, set OPENAI_API_KEY or ANTHROPIC_API_KEY in the MCP server environment.",
|
|
2004
2010
|
sources,
|
|
2005
2011
|
findings: [],
|
|
2006
2012
|
researchSummary: {
|
|
@@ -143,6 +143,26 @@ export class ContentAnalyzer {
|
|
|
143
143
|
return this.cjkScriptCounts(text).share >= 0.1;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* True when the text is written mostly in the Latin alphabet.
|
|
148
|
+
*
|
|
149
|
+
* The analyser's entity extraction (compromise) and its syllable counter are
|
|
150
|
+
* both English/Latin-only. Run over Russian they do not degrade, they invent:
|
|
151
|
+
* "дождь и порывистый" ("rain and gusty") came back as an organization, and
|
|
152
|
+
* every Cyrillic word counted as one syllable, which put a weather report at
|
|
153
|
+
* Flesch 100, "Very Easy" (R19). Text with too few Latin letters to work on
|
|
154
|
+
* is reported as not-applicable rather than analysed anyway.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} text - Text to analyze
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
isLatinScriptText(text) {
|
|
160
|
+
const letters = (text || '').match(/\p{L}/gu);
|
|
161
|
+
if (!letters || letters.length === 0) return true; // nothing to misread
|
|
162
|
+
const latin = (text || '').match(/\p{Script=Latin}/gu);
|
|
163
|
+
return (latin ? latin.length : 0) / letters.length >= 0.5;
|
|
164
|
+
}
|
|
165
|
+
|
|
146
166
|
/**
|
|
147
167
|
* Tokenize text into words with Intl.Segmenter (dictionary-based for CJK
|
|
148
168
|
* scripts, whitespace/boundary-based otherwise). Only word-like segments
|
|
@@ -534,6 +554,17 @@ export class ContentAnalyzer {
|
|
|
534
554
|
*/
|
|
535
555
|
async extractEntities(text, options = {}) {
|
|
536
556
|
try {
|
|
557
|
+
// compromise is an English model. On Russian it does not find fewer
|
|
558
|
+
// entities, it finds wrong ones — "дождь и порывистый" as an
|
|
559
|
+
// organization (R19). Say so rather than fill the categories with noise.
|
|
560
|
+
if (!this.isLatinScriptText(text)) {
|
|
561
|
+
return {
|
|
562
|
+
people: [], places: [], organizations: [], dates: [], money: [], other: [],
|
|
563
|
+
notApplicable: 'entity-extraction-requires-latin-script',
|
|
564
|
+
summary: { totalEntities: 0, uniqueEntities: 0, entityDensity: 0 }
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
537
568
|
const doc = nlp(text);
|
|
538
569
|
|
|
539
570
|
// compromise's out('array') keeps adjoining punctuation ("Craigslist.",
|
|
@@ -754,10 +785,13 @@ export class ContentAnalyzer {
|
|
|
754
785
|
syllables: totalSyllables
|
|
755
786
|
};
|
|
756
787
|
|
|
757
|
-
// Flesch is syllable-based and
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
788
|
+
// Flesch is syllable-based, and countSyllables() only knows the Latin
|
|
789
|
+
// vowels — every Cyrillic, Greek or Arabic word scores one syllable, so
|
|
790
|
+
// the formula returns ~100 ("Very Easy") for any of them (R19). Report
|
|
791
|
+
// the metrics with an explicit reason instead of a fabricated score — a
|
|
792
|
+
// null return above already means "failed", so the two stay
|
|
793
|
+
// distinguishable.
|
|
794
|
+
if (isCjk || !this.isLatinScriptText(text)) {
|
|
761
795
|
return {
|
|
762
796
|
notApplicable: 'flesch-requires-syllable-based-language',
|
|
763
797
|
metrics
|
|
@@ -1070,16 +1104,38 @@ export class ContentAnalyzer {
|
|
|
1070
1104
|
* @returns {boolean} - True if stop word
|
|
1071
1105
|
*/
|
|
1072
1106
|
isStopWord(word) {
|
|
1073
|
-
|
|
1074
|
-
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
|
|
1075
|
-
'from', 'as', 'is', 'was', 'are', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
1076
|
-
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can',
|
|
1077
|
-
'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me',
|
|
1078
|
-
'him', 'her', 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their'
|
|
1079
|
-
];
|
|
1080
|
-
|
|
1081
|
-
return stopWords.includes(word.toLowerCase());
|
|
1107
|
+
return STOP_WORDS.has(word.toLowerCase());
|
|
1082
1108
|
}
|
|
1083
1109
|
}
|
|
1084
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Stop words, by script. Russian was added after a weather report analysed with
|
|
1113
|
+
* `analyze_content` returned bare prepositions and conjunctions among its
|
|
1114
|
+
* topics — "над" ("over") ranked as one (R19). R17 had added Japanese the same
|
|
1115
|
+
* way; every language whose function words are not on this list gets them back
|
|
1116
|
+
* as topics.
|
|
1117
|
+
*/
|
|
1118
|
+
const STOP_WORDS = new Set([
|
|
1119
|
+
// English
|
|
1120
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
|
|
1121
|
+
'from', 'as', 'is', 'was', 'are', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
1122
|
+
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can',
|
|
1123
|
+
'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me',
|
|
1124
|
+
'him', 'her', 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their',
|
|
1125
|
+
// Russian — prepositions, conjunctions, particles and pronouns
|
|
1126
|
+
'и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', 'как', 'а', 'то', 'все', 'она',
|
|
1127
|
+
'так', 'его', 'но', 'да', 'ты', 'к', 'у', 'же', 'вы', 'за', 'бы', 'по', 'только', 'ее',
|
|
1128
|
+
'мне', 'было', 'вот', 'от', 'меня', 'еще', 'нет', 'о', 'из', 'ему', 'теперь', 'когда',
|
|
1129
|
+
'даже', 'ну', 'вдруг', 'ли', 'если', 'уже', 'или', 'ни', 'быть', 'был', 'него', 'до',
|
|
1130
|
+
'вас', 'нибудь', 'опять', 'уж', 'вам', 'ведь', 'там', 'потом', 'себя', 'ничего', 'ей',
|
|
1131
|
+
'может', 'они', 'тут', 'где', 'есть', 'надо', 'ней', 'для', 'мы', 'тебя', 'их', 'чем',
|
|
1132
|
+
'была', 'сам', 'чтоб', 'без', 'будто', 'чего', 'раз', 'тоже', 'себе', 'под', 'будет',
|
|
1133
|
+
'ж', 'тогда', 'кто', 'этот', 'того', 'потому', 'этого', 'какой', 'совсем', 'ним',
|
|
1134
|
+
'здесь', 'этом', 'один', 'почти', 'мой', 'тем', 'чтобы', 'нее', 'были', 'куда', 'зачем',
|
|
1135
|
+
'всех', 'никогда', 'можно', 'при', 'наконец', 'два', 'об', 'другой', 'хоть', 'после',
|
|
1136
|
+
'над', 'больше', 'тот', 'через', 'эти', 'нас', 'про', 'всего', 'них', 'какая', 'много',
|
|
1137
|
+
'разве', 'три', 'эту', 'моя', 'впрочем', 'свою', 'этой', 'перед', 'иногда', 'лучше',
|
|
1138
|
+
'чуть', 'том', 'нельзя', 'такой', 'им', 'более', 'всегда', 'конечно', 'всю', 'между'
|
|
1139
|
+
]);
|
|
1140
|
+
|
|
1085
1141
|
export default ContentAnalyzer;
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import { OpenAIProvider } from './OpenAIProvider.js';
|
|
2
2
|
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
3
|
+
import { validateFieldsAgainstSchema } from '../../utils/schemaValidate.js';
|
|
3
4
|
import { AnthropicProvider } from './AnthropicProvider.js';
|
|
4
5
|
import { OllamaProvider } from './OllamaProvider.js';
|
|
5
6
|
import { Logger } from '../../utils/Logger.js';
|
|
6
7
|
import { isJudgementModel } from '../../utils/ollamaConfig.js';
|
|
7
8
|
|
|
9
|
+
// Output-token ceilings for structured extraction. A schema of scalar fields
|
|
10
|
+
// answers in a few hundred tokens; one asking for an array is writing rows,
|
|
11
|
+
// and the same ceiling truncates it mid-object.
|
|
12
|
+
const SCALAR_OUTPUT_CEILING = 2000;
|
|
13
|
+
const ROW_OUTPUT_CEILING = 4000;
|
|
14
|
+
const ROW_FIELD_ALLOWANCE = 1200;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True when a JSON string that failed to parse simply stops partway — the
|
|
18
|
+
* signature of a response that hit its output-token limit, as opposed to one
|
|
19
|
+
* that is malformed from the start.
|
|
20
|
+
*/
|
|
21
|
+
function endsMidJson(text) {
|
|
22
|
+
const trimmed = text.trim();
|
|
23
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
|
|
24
|
+
// A complete document ends on its closing brace or bracket. Anything else
|
|
25
|
+
// — a dangling comma, an unterminated string, a half-written key — means
|
|
26
|
+
// the writer stopped rather than finished.
|
|
27
|
+
return !trimmed.endsWith('}') && !trimmed.endsWith(']');
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
/**
|
|
9
31
|
* LLM Manager
|
|
10
32
|
* Manages multiple LLM providers and provides unified interface
|
|
@@ -916,9 +938,17 @@ Return the list and nothing else.`;
|
|
|
916
938
|
? content.substring(0, maxContentLength) + '...'
|
|
917
939
|
: content;
|
|
918
940
|
|
|
919
|
-
// Scale maxTokens with schema complexity
|
|
920
|
-
|
|
921
|
-
|
|
941
|
+
// Scale maxTokens with schema complexity. An array-valued property is one
|
|
942
|
+
// key but many rows of output, so counting keys alone budgets a 250-row
|
|
943
|
+
// table exactly like a single string: the response stops mid-object and
|
|
944
|
+
// the whole extraction is thrown away as unparseable (R19).
|
|
945
|
+
const fields = Object.values(schema.properties || {});
|
|
946
|
+
const arrayFields = fields.filter((field) => field?.type === 'array').length;
|
|
947
|
+
const ceiling = arrayFields > 0 ? ROW_OUTPUT_CEILING : SCALAR_OUTPUT_CEILING;
|
|
948
|
+
const scaledTokens = Math.min(
|
|
949
|
+
ceiling,
|
|
950
|
+
Math.max(maxTokens, fields.length * 100 + arrayFields * ROW_FIELD_ALLOWANCE + 500)
|
|
951
|
+
);
|
|
922
952
|
|
|
923
953
|
const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Use null for any field the content does not state — never guess, infer, or fill a value from memory. Do not include any explanation or markdown — only the raw JSON object.`;
|
|
924
954
|
|
|
@@ -933,76 +963,74 @@ ${truncatedContent}
|
|
|
933
963
|
|
|
934
964
|
Extract the data and return valid JSON:`;
|
|
935
965
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
966
|
+
// Two attempts, the second with twice the budget: the failure this guards
|
|
967
|
+
// against is a response cut off mid-object, which re-asking at the same
|
|
968
|
+
// size reproduces exactly. Mirrors the retry in synthesizeFindings().
|
|
969
|
+
let lastError;
|
|
970
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
971
|
+
const budget = attempt === 0 ? scaledTokens : Math.min(ceiling * 2, scaledTokens * 2);
|
|
972
|
+
try {
|
|
973
|
+
const response = await this.generateCompletion(extractionPrompt, {
|
|
974
|
+
systemPrompt,
|
|
975
|
+
maxTokens: budget,
|
|
976
|
+
temperature: 0.1,
|
|
977
|
+
// Constrain the output to the caller's shape with every field
|
|
978
|
+
// nullable, so a model shown content that does not state a field can
|
|
979
|
+
// answer null instead of being decoded into an invented string. Small
|
|
980
|
+
// local models otherwise wrap the JSON in prose and the parse throws.
|
|
981
|
+
format: extractionFormat(schema)
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Strip markdown code fences if present
|
|
985
|
+
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
986
|
+
let parsed;
|
|
987
|
+
try {
|
|
988
|
+
parsed = JSON.parse(cleaned);
|
|
989
|
+
} catch (parseError) {
|
|
990
|
+
// Name the cause when the JSON simply stops: "unexpected end of
|
|
991
|
+
// input at position 2608" tells a caller nothing they can act on,
|
|
992
|
+
// whereas "cut off at the 1000-token limit" points at the schema.
|
|
993
|
+
throw endsMidJson(cleaned)
|
|
994
|
+
? new Error(`model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`)
|
|
995
|
+
: parseError;
|
|
996
|
+
}
|
|
947
997
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
// result "llm", so a failed LLM call was returned as a high-confidence
|
|
964
|
-
// LLM extraction.
|
|
965
|
-
return { ...this.fallbackStructuredExtraction(content, schema), error: error.message };
|
|
998
|
+
const validation = this.validateAgainstSchema(parsed, schema);
|
|
999
|
+
return {
|
|
1000
|
+
data: parsed,
|
|
1001
|
+
method: 'llm',
|
|
1002
|
+
valid: validation.valid,
|
|
1003
|
+
validationErrors: validation.errors
|
|
1004
|
+
};
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
lastError = error;
|
|
1007
|
+
this.logger.warn('LLM structured extraction attempt failed', {
|
|
1008
|
+
attempt: attempt + 1,
|
|
1009
|
+
budget,
|
|
1010
|
+
error: error.message
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
966
1013
|
}
|
|
1014
|
+
|
|
1015
|
+
this.logger.warn('LLM structured extraction failed, using fallback', { error: lastError.message });
|
|
1016
|
+
// Report which path produced the data. Callers previously labelled this
|
|
1017
|
+
// result "llm", so a failed LLM call was returned as a high-confidence
|
|
1018
|
+
// LLM extraction.
|
|
1019
|
+
return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
|
|
967
1020
|
}
|
|
968
1021
|
|
|
969
1022
|
/**
|
|
970
|
-
* Validate a parsed object against a
|
|
1023
|
+
* Validate a parsed object against a JSON Schema.
|
|
1024
|
+
*
|
|
1025
|
+
* Delegates to the shared validator so that it descends into `items` and
|
|
1026
|
+
* nested `properties`. This used to be a hand-rolled one-level check, which
|
|
1027
|
+
* passed `{countries: ["a string"]}` against
|
|
1028
|
+
* `{countries: {type: 'array', items: {type: 'object'}}}` as valid — the
|
|
1029
|
+
* top-level value really was an array, and nothing looked inside it, so an
|
|
1030
|
+
* extraction full of stray page text was returned as `valid: true` (R19).
|
|
971
1031
|
*/
|
|
972
1032
|
validateAgainstSchema(data, schema) {
|
|
973
|
-
|
|
974
|
-
const properties = schema.properties || {};
|
|
975
|
-
const required = schema.required || [];
|
|
976
|
-
|
|
977
|
-
for (const field of required) {
|
|
978
|
-
// The decoder is told to answer null for a field the content never
|
|
979
|
-
// states, so a null here is "not filled in", the same as absent.
|
|
980
|
-
if (!(field in data) || data[field] === null) {
|
|
981
|
-
errors.push(`Missing required field: ${field}`);
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
986
|
-
if (key in data) {
|
|
987
|
-
const value = data[key];
|
|
988
|
-
// A null in a field the schema does not require is the honest answer
|
|
989
|
-
// for content that never states it, not a type error (typeof null is
|
|
990
|
-
// 'object', which used to read as "expected number, got object").
|
|
991
|
-
if (value === null) continue;
|
|
992
|
-
const expectedType = fieldSchema.type;
|
|
993
|
-
if (expectedType) {
|
|
994
|
-
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
995
|
-
if (actualType !== expectedType) {
|
|
996
|
-
errors.push(`Field "${key}": expected ${expectedType}, got ${actualType}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
if (fieldSchema.enum && !fieldSchema.enum.includes(value)) {
|
|
1000
|
-
errors.push(`Field "${key}": value "${value}" not in enum ${JSON.stringify(fieldSchema.enum)}`);
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
return { valid: errors.length === 0, errors };
|
|
1033
|
+
return validateFieldsAgainstSchema(data, schema);
|
|
1006
1034
|
}
|
|
1007
1035
|
|
|
1008
1036
|
/**
|
|
@@ -332,7 +332,7 @@ const searchWebShape = {
|
|
|
332
332
|
// ── extract_structured ───────────────────────────────────────────────────────
|
|
333
333
|
|
|
334
334
|
const extractStructuredShape = {
|
|
335
|
-
success: z.boolean().optional().describe('False when the extraction errored or a required field came back missing or
|
|
335
|
+
success: z.boolean().optional().describe('False when the extraction errored, or a required field came back missing, empty, or in the wrong shape'),
|
|
336
336
|
url: z.string().optional(),
|
|
337
337
|
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
|
|
338
338
|
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
|
|
@@ -12,6 +12,7 @@ import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
|
12
12
|
import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
|
|
13
13
|
import { extractMainContent } from '../scrape/_mainContent.js';
|
|
14
14
|
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
15
|
+
import { validateAgainstSchema, validateFieldsAgainstSchema } from '../../utils/schemaValidate.js';
|
|
15
16
|
|
|
16
17
|
// Semantic element selectors for well-known field names, tried as a last
|
|
17
18
|
// resort in the CSS fallback so common fields (e.g. "title") still resolve when
|
|
@@ -394,7 +395,21 @@ export class ExtractStructuredTool {
|
|
|
394
395
|
const missingRequired = (schema.required || []).filter(
|
|
395
396
|
(field) => isEmptyValue((extractionResult.data || {})[field])
|
|
396
397
|
);
|
|
397
|
-
|
|
398
|
+
// Same reasoning for a required field that arrived in the wrong shape:
|
|
399
|
+
// `{countries: ["a stray line of page text"]}` for an array of objects
|
|
400
|
+
// is a failed extraction too, and it is neither missing nor empty, so
|
|
401
|
+
// the check above waves it through (R19). Re-validated per field rather
|
|
402
|
+
// than parsed out of the error strings.
|
|
403
|
+
const invalidRequired = extractionResult.valid !== true
|
|
404
|
+
? (schema.required || []).filter((field) => {
|
|
405
|
+
if (missingRequired.includes(field)) return false;
|
|
406
|
+
const fieldSchema = schema.properties?.[field];
|
|
407
|
+
if (!fieldSchema) return false;
|
|
408
|
+
return validateAgainstSchema((extractionResult.data || {})[field], fieldSchema).valid !== true;
|
|
409
|
+
})
|
|
410
|
+
: [];
|
|
411
|
+
const failedRequired = extractionResult.valid !== true
|
|
412
|
+
&& (missingRequired.length > 0 || invalidRequired.length > 0);
|
|
398
413
|
|
|
399
414
|
return {
|
|
400
415
|
success: !failedRequired,
|
|
@@ -405,7 +420,12 @@ export class ExtractStructuredTool {
|
|
|
405
420
|
schema_used: schema,
|
|
406
421
|
processingTime: Date.now() - startTime,
|
|
407
422
|
...(failedRequired
|
|
408
|
-
? {
|
|
423
|
+
? {
|
|
424
|
+
error: `Required field(s) ${[
|
|
425
|
+
missingRequired.length ? `missing or empty: ${missingRequired.join(', ')}` : '',
|
|
426
|
+
invalidRequired.length ? `wrong shape: ${invalidRequired.join(', ')}` : ''
|
|
427
|
+
].filter(Boolean).join('; ')}`
|
|
428
|
+
}
|
|
409
429
|
: {}),
|
|
410
430
|
validation: {
|
|
411
431
|
valid: extractionResult.valid || false,
|
|
@@ -566,18 +586,15 @@ export class ExtractStructuredTool {
|
|
|
566
586
|
return null; // No fields found via CSS, let keyword fallback handle it
|
|
567
587
|
}
|
|
568
588
|
|
|
569
|
-
// Validate
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
errors.push(`Missing required field: ${field}`);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
589
|
+
// Validate the extracted fields. This used to check only that each
|
|
590
|
+
// required key was present, which is why a fallback that swept three
|
|
591
|
+
// stray <p> elements into an array-of-objects field reported valid: true
|
|
592
|
+
// (R19) — presence says nothing about shape.
|
|
593
|
+
const { valid, errors } = validateFieldsAgainstSchema(extracted, schema);
|
|
577
594
|
|
|
578
595
|
return {
|
|
579
596
|
data: extracted,
|
|
580
|
-
valid
|
|
597
|
+
valid,
|
|
581
598
|
validationErrors: errors,
|
|
582
599
|
extractionNotes: ['Used CSS selector fallback extraction']
|
|
583
600
|
};
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* Pass provider: "openai" | "anthropic" with the matching API key to use a cloud model.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { z } from 'zod';
|
|
11
10
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
11
|
import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
|
|
13
12
|
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
14
13
|
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
14
|
+
import { validateAgainstSchema } from '../../utils/schemaValidate.js';
|
|
15
15
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
16
16
|
// D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
|
|
17
17
|
let _SamplingClient = null;
|
|
@@ -173,48 +173,6 @@ function buildInputSchema(schema) {
|
|
|
173
173
|
return format === 'json' ? { type: 'object', properties: {}, additionalProperties: true } : format;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/**
|
|
177
|
-
* Build a zod validator from a JSON-Schema-like hint. Best-effort: unknown
|
|
178
|
-
* shapes fall back to `z.any()` so validation never rejects on constructs the
|
|
179
|
-
* converter does not understand.
|
|
180
|
-
*/
|
|
181
|
-
function jsonSchemaToZod(schema) {
|
|
182
|
-
if (!schema || typeof schema !== 'object') return z.any();
|
|
183
|
-
|
|
184
|
-
// Flat hint map (no `type`/`properties`) → treat values as field hints.
|
|
185
|
-
const isJsonSchema = schema.type || schema.properties || schema.items;
|
|
186
|
-
if (!isJsonSchema) {
|
|
187
|
-
const shape = {};
|
|
188
|
-
for (const [key, val] of Object.entries(schema)) {
|
|
189
|
-
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
|
|
190
|
-
}
|
|
191
|
-
return z.object(shape).passthrough();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
switch (schema.type) {
|
|
195
|
-
case 'string': return z.string();
|
|
196
|
-
case 'number':
|
|
197
|
-
case 'integer': return z.number();
|
|
198
|
-
case 'boolean': return z.boolean();
|
|
199
|
-
case 'null': return z.null();
|
|
200
|
-
case 'array': return z.array(schema.items ? jsonSchemaToZod(schema.items) : z.any());
|
|
201
|
-
case 'object': {
|
|
202
|
-
const shape = {};
|
|
203
|
-
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
204
|
-
for (const [key, val] of Object.entries(schema.properties || {})) {
|
|
205
|
-
const field = jsonSchemaToZod(val);
|
|
206
|
-
// The model is told to answer null for a field the content never
|
|
207
|
-
// states, so null is the honest answer for a field the schema does
|
|
208
|
-
// not require — not a type violation. A required field stays strict:
|
|
209
|
-
// null there is exactly what the caller needs to hear about.
|
|
210
|
-
shape[key] = required.includes(key) ? field : field.nullable().optional();
|
|
211
|
-
}
|
|
212
|
-
return z.object(shape).passthrough();
|
|
213
|
-
}
|
|
214
|
-
default: return z.any();
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
176
|
/** JSON Schema type keywords, used to spot a type declaration posing as a value. */
|
|
219
177
|
const SCHEMA_TYPE_KEYWORDS = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']);
|
|
220
178
|
|
|
@@ -281,25 +239,6 @@ function hasNoExtractableData(parsed) {
|
|
|
281
239
|
return Object.values(parsed).every(hasNoExtractableData);
|
|
282
240
|
}
|
|
283
241
|
|
|
284
|
-
/**
|
|
285
|
-
* Validate parsed output against the schema hint.
|
|
286
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
287
|
-
*/
|
|
288
|
-
function validateAgainstSchema(parsed, schema) {
|
|
289
|
-
try {
|
|
290
|
-
const validator = jsonSchemaToZod(schema);
|
|
291
|
-
const result = validator.safeParse(parsed);
|
|
292
|
-
if (result.success) return { valid: true, errors: [] };
|
|
293
|
-
return {
|
|
294
|
-
valid: false,
|
|
295
|
-
errors: result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
296
|
-
};
|
|
297
|
-
} catch {
|
|
298
|
-
// Converter failure should not block extraction — treat as unvalidated.
|
|
299
|
-
return { valid: true, errors: [] };
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
242
|
// ── OpenAI call ───────────────────────────────────────────────────────────────
|
|
304
243
|
|
|
305
244
|
async function callOpenAI({ apiKey, model, systemMessage, userMessage, maxTokens }) {
|
|
@@ -451,8 +451,9 @@ export class DeepResearchTool {
|
|
|
451
451
|
* Format research results according to output preferences
|
|
452
452
|
*/
|
|
453
453
|
formatResults(results, params) {
|
|
454
|
-
// Raw evidence mode (no LLM configured
|
|
455
|
-
//
|
|
454
|
+
// Raw evidence mode (no LLM configured, or the token budget ran out
|
|
455
|
+
// mid-run): apply lightweight formatting so outputFormat is not silently
|
|
456
|
+
// ignored, and rank sources by credibility. `results.note` says which.
|
|
456
457
|
if (results.synthesisMode === 'raw_evidence') {
|
|
457
458
|
const rankedSources = (results.sources || [])
|
|
458
459
|
.slice()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON-Schema → zod validation.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `src/tools/extract/extractWithLlm.js`, where it was local and
|
|
5
|
+
* unexported, so that every consumer of LLM-decoded JSON validates the same
|
|
6
|
+
* way. `LLMManager.validateAgainstSchema` used to hand-roll its own check that
|
|
7
|
+
* only ever looked one level deep: `{countries: ["a string", "another"]}`
|
|
8
|
+
* against `{countries: {type: 'array', items: {type: 'object'}}}` reported
|
|
9
|
+
* `valid: true` because the top-level value was, in fact, an array (R19).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a zod validator from a JSON-Schema-like hint. Best-effort: unknown
|
|
16
|
+
* shapes fall back to `z.any()` so validation never rejects on constructs the
|
|
17
|
+
* converter does not understand.
|
|
18
|
+
*/
|
|
19
|
+
export function jsonSchemaToZod(schema) {
|
|
20
|
+
if (!schema || typeof schema !== 'object') return z.any();
|
|
21
|
+
|
|
22
|
+
// Flat hint map (no `type`/`properties`) → treat values as field hints.
|
|
23
|
+
const isJsonSchema = schema.type || schema.properties || schema.items;
|
|
24
|
+
if (!isJsonSchema) {
|
|
25
|
+
const shape = {};
|
|
26
|
+
for (const [key, val] of Object.entries(schema)) {
|
|
27
|
+
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
|
|
28
|
+
}
|
|
29
|
+
return z.object(shape).passthrough();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
switch (schema.type) {
|
|
33
|
+
case 'string': return z.string();
|
|
34
|
+
case 'number':
|
|
35
|
+
case 'integer': return z.number();
|
|
36
|
+
case 'boolean': return z.boolean();
|
|
37
|
+
case 'null': return z.null();
|
|
38
|
+
case 'array': return z.array(schema.items ? jsonSchemaToZod(schema.items) : z.any());
|
|
39
|
+
case 'object': {
|
|
40
|
+
const shape = {};
|
|
41
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
42
|
+
for (const [key, val] of Object.entries(schema.properties || {})) {
|
|
43
|
+
const field = jsonSchemaToZod(val);
|
|
44
|
+
// The model is told to answer null for a field the content never
|
|
45
|
+
// states, so null is the honest answer for a field the schema does
|
|
46
|
+
// not require — not a type violation. A required field stays strict:
|
|
47
|
+
// null there is exactly what the caller needs to hear about.
|
|
48
|
+
shape[key] = required.includes(key) ? field : field.nullable().optional();
|
|
49
|
+
}
|
|
50
|
+
return z.object(shape).passthrough();
|
|
51
|
+
}
|
|
52
|
+
default: return z.any();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate parsed output against the schema hint.
|
|
58
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
export function validateAgainstSchema(parsed, schema) {
|
|
61
|
+
try {
|
|
62
|
+
const validator = jsonSchemaToZod(schema);
|
|
63
|
+
const result = validator.safeParse(parsed);
|
|
64
|
+
if (result.success) return { valid: true, errors: [] };
|
|
65
|
+
return {
|
|
66
|
+
valid: false,
|
|
67
|
+
errors: result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// Converter failure should not block extraction — treat as unvalidated.
|
|
71
|
+
return { valid: true, errors: [] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate against a schema and report problems per field, in the wording
|
|
77
|
+
* callers see in tool output: "Missing required field: x" and
|
|
78
|
+
* `Field "x": expected number, got string`, with a dotted path for anything
|
|
79
|
+
* nested (`Field "countries.0.capital": ...`).
|
|
80
|
+
*
|
|
81
|
+
* Same structural check as validateAgainstSchema — this differs only in how
|
|
82
|
+
* the failures are worded, and in also checking `enum`, which the zod
|
|
83
|
+
* converter does not carry.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function validateFieldsAgainstSchema(data, schema) {
|
|
88
|
+
const required = Array.isArray(schema?.required) ? schema.required : [];
|
|
89
|
+
const properties = schema?.properties || {};
|
|
90
|
+
const errors = [];
|
|
91
|
+
|
|
92
|
+
let issues = [];
|
|
93
|
+
try {
|
|
94
|
+
const result = jsonSchemaToZod(schema).safeParse(data);
|
|
95
|
+
if (!result.success) issues = result.error.issues;
|
|
96
|
+
} catch {
|
|
97
|
+
// The converter is best-effort and falls back to z.any() rather than
|
|
98
|
+
// throwing, so this is unreachable in practice. Treat it as unvalidated
|
|
99
|
+
// rather than failing an extraction on a validator bug.
|
|
100
|
+
return { valid: true, errors: [] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const issue of issues) {
|
|
104
|
+
const path = issue.path;
|
|
105
|
+
const value = path.reduce((acc, key) => (acc == null ? undefined : acc[key]), data);
|
|
106
|
+
// A required field the decoder left null is "not filled in", the same as
|
|
107
|
+
// absent — the caller wants to hear it is missing, not that null is the
|
|
108
|
+
// wrong type. Optional nulls never reach here: the converter allows them.
|
|
109
|
+
if (path.length === 1 && required.includes(path[0]) && (value === null || value === undefined)) {
|
|
110
|
+
errors.push(`Missing required field: ${path[0]}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const where = path.length ? path.join('.') : '(root)';
|
|
114
|
+
if (issue.code === 'invalid_type' && issue.expected) {
|
|
115
|
+
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
116
|
+
errors.push(`Field "${where}": expected ${issue.expected}, got ${actualType}`);
|
|
117
|
+
} else {
|
|
118
|
+
errors.push(`Field "${where}": ${issue.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The converter carries no `enum`, so that check stays here. Top level only.
|
|
123
|
+
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
124
|
+
const value = data?.[key];
|
|
125
|
+
if (value === null || value === undefined) continue;
|
|
126
|
+
if (fieldSchema?.enum && !fieldSchema.enum.includes(value)) {
|
|
127
|
+
errors.push(`Field "${key}": value "${value}" not in enum ${JSON.stringify(fieldSchema.enum)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A malformed array can raise one issue per element; the full list is pushed
|
|
132
|
+
// into tool output, so cap it.
|
|
133
|
+
const MAX_REPORTED = 10;
|
|
134
|
+
const reported = errors.length > MAX_REPORTED
|
|
135
|
+
? [...errors.slice(0, MAX_REPORTED), `…and ${errors.length - MAX_REPORTED} more validation errors`]
|
|
136
|
+
: errors;
|
|
137
|
+
|
|
138
|
+
return { valid: errors.length === 0, errors: reported };
|
|
139
|
+
}
|