crawlforge-mcp-server 6.1.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +5 -97
- package/src/cli/commands/monitor.js +18 -4
- 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/skills/agent-skills/crawlforge-change-tracking/SKILL.md +53 -14
- 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/tools/tracking/trackChanges/hosted.js +176 -0
- package/src/tools/tracking/trackChanges/index.js +123 -7
- package/src/tools/tracking/trackChanges/notifier.js +5 -4
- package/src/tools/tracking/trackChanges/schema.js +36 -22
- package/src/utils/schemaValidate.js +139 -0
- package/src/core/AlertNotificationSystem.js +0 -602
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
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
|
@@ -23,7 +23,7 @@ import { ListOllamaModelsTool } from "./src/tools/extract/listOllamaModels.js";
|
|
|
23
23
|
import { BatchScrapeTool } from "./src/tools/advanced/BatchScrapeTool.js";
|
|
24
24
|
import { ScrapeWithActionsTool } from "./src/tools/advanced/ScrapeWithActionsTool.js";
|
|
25
25
|
import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
|
|
26
|
-
import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
|
|
26
|
+
import { TrackChangesTool, TRACK_CHANGES_INPUT_SHAPE } from "./src/tools/tracking/trackChanges/index.js";
|
|
27
27
|
import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
28
28
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
29
29
|
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
@@ -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.0",
|
|
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",
|
|
@@ -1138,103 +1138,11 @@ registerToolIfEnabled("agent", {
|
|
|
1138
1138
|
|
|
1139
1139
|
// Tool: track_changes
|
|
1140
1140
|
registerToolIfEnabled("track_changes", {
|
|
1141
|
-
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1141
|
+
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1142
1142
|
annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1143
|
+
// The tool module owns the schema (G5); this is the same shape it validates with.
|
|
1143
1144
|
inputSchema: {
|
|
1144
|
-
|
|
1145
|
-
operation: z.enum([
|
|
1146
|
-
'create_baseline', 'compare', 'monitor', 'get_history', 'get_stats',
|
|
1147
|
-
'create_scheduled_monitor', 'stop_scheduled_monitor', 'list_scheduled_monitors', 'get_dashboard',
|
|
1148
|
-
'export_history', 'create_alert_rule', 'generate_trend_report', 'get_monitoring_templates'
|
|
1149
|
-
]).default('compare').describe("Tracking operation to perform"),
|
|
1150
|
-
content: z.string().optional().describe("Content to compare against baseline"),
|
|
1151
|
-
html: z.string().optional().describe("HTML content to compare against baseline"),
|
|
1152
|
-
trackingOptions: z.object({
|
|
1153
|
-
granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
|
|
1154
|
-
trackText: z.boolean().default(true),
|
|
1155
|
-
trackStructure: z.boolean().default(true),
|
|
1156
|
-
trackAttributes: z.boolean().default(false),
|
|
1157
|
-
trackImages: z.boolean().default(false),
|
|
1158
|
-
trackLinks: z.boolean().default(true),
|
|
1159
|
-
ignoreWhitespace: z.boolean().default(true),
|
|
1160
|
-
ignoreCase: z.boolean().default(false),
|
|
1161
|
-
customSelectors: z.array(z.string()).optional(),
|
|
1162
|
-
excludeSelectors: z.array(z.string()).optional(),
|
|
1163
|
-
significanceThresholds: z.object({
|
|
1164
|
-
minor: z.number().min(0).max(1).default(0.1),
|
|
1165
|
-
moderate: z.number().min(0).max(1).default(0.3),
|
|
1166
|
-
major: z.number().min(0).max(1).default(0.7)
|
|
1167
|
-
}).optional()
|
|
1168
|
-
}).optional().describe("Options for how changes are tracked and compared"),
|
|
1169
|
-
monitoringOptions: z.object({
|
|
1170
|
-
enabled: z.boolean().default(false),
|
|
1171
|
-
interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
|
|
1172
|
-
maxRetries: z.number().min(0).max(5).default(3),
|
|
1173
|
-
retryDelay: z.number().min(1000).max(60000).default(5000),
|
|
1174
|
-
notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).default('moderate'),
|
|
1175
|
-
enableWebhook: z.boolean().default(false),
|
|
1176
|
-
webhookUrl: z.string().url().optional(),
|
|
1177
|
-
webhookSecret: z.string().optional()
|
|
1178
|
-
}).optional().describe("Monitoring schedule and notification settings"),
|
|
1179
|
-
storageOptions: z.object({
|
|
1180
|
-
enableSnapshots: z.boolean().default(true),
|
|
1181
|
-
retainHistory: z.boolean().default(true),
|
|
1182
|
-
maxHistoryEntries: z.number().min(1).max(1000).default(100),
|
|
1183
|
-
compressionEnabled: z.boolean().default(true),
|
|
1184
|
-
deltaStorageEnabled: z.boolean().default(true)
|
|
1185
|
-
}).optional().describe("Storage and history retention settings"),
|
|
1186
|
-
queryOptions: z.object({
|
|
1187
|
-
limit: z.number().min(1).max(500).default(50),
|
|
1188
|
-
offset: z.number().min(0).default(0),
|
|
1189
|
-
startTime: z.number().optional(),
|
|
1190
|
-
endTime: z.number().optional(),
|
|
1191
|
-
includeContent: z.boolean().default(false),
|
|
1192
|
-
significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
|
|
1193
|
-
}).optional().describe("Query options for history and stats retrieval"),
|
|
1194
|
-
notificationOptions: z.object({
|
|
1195
|
-
webhook: z.object({
|
|
1196
|
-
enabled: z.boolean().default(false),
|
|
1197
|
-
url: z.string().url().optional(),
|
|
1198
|
-
method: z.enum(['POST', 'PUT']).default('POST'),
|
|
1199
|
-
headers: z.record(z.string()).optional(),
|
|
1200
|
-
signingSecret: z.string().optional(),
|
|
1201
|
-
includeContent: z.boolean().default(false)
|
|
1202
|
-
}).optional(),
|
|
1203
|
-
slack: z.object({
|
|
1204
|
-
enabled: z.boolean().default(false),
|
|
1205
|
-
webhookUrl: z.string().url().optional(),
|
|
1206
|
-
channel: z.string().optional(),
|
|
1207
|
-
username: z.string().optional()
|
|
1208
|
-
}).optional()
|
|
1209
|
-
}).optional().describe("Notification configuration for webhooks and Slack"),
|
|
1210
|
-
scheduledMonitorOptions: z.object({
|
|
1211
|
-
schedule: z.string().optional().describe("Optional cron expression (power users)"),
|
|
1212
|
-
templateId: z.string().optional(),
|
|
1213
|
-
enabled: z.boolean().default(true),
|
|
1214
|
-
interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
|
|
1215
|
-
goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
|
|
1216
|
-
monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
|
|
1217
|
-
notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional()
|
|
1218
|
-
}).optional().describe("Scheduled monitoring: recurring compare + notify, optional plain-English goal"),
|
|
1219
|
-
alertRuleOptions: z.object({
|
|
1220
|
-
ruleId: z.string().optional(),
|
|
1221
|
-
condition: z.string().optional(),
|
|
1222
|
-
actions: z.array(z.enum(['webhook', 'email', 'slack'])).optional(),
|
|
1223
|
-
throttle: z.number().min(0).optional(),
|
|
1224
|
-
priority: z.enum(['low', 'medium', 'high']).optional()
|
|
1225
|
-
}).optional().describe("Alert rule configuration for change notifications"),
|
|
1226
|
-
exportOptions: z.object({
|
|
1227
|
-
format: z.enum(['json', 'csv']).default('json'),
|
|
1228
|
-
startTime: z.number().optional(),
|
|
1229
|
-
endTime: z.number().optional(),
|
|
1230
|
-
includeContent: z.boolean().default(false),
|
|
1231
|
-
includeSnapshots: z.boolean().default(false)
|
|
1232
|
-
}).optional().describe("Export options for change history data"),
|
|
1233
|
-
dashboardOptions: z.object({
|
|
1234
|
-
includeRecentAlerts: z.boolean().default(true),
|
|
1235
|
-
includeTrends: z.boolean().default(true),
|
|
1236
|
-
includeMonitorStatus: z.boolean().default(true)
|
|
1237
|
-
}).optional().describe("Dashboard display options"),
|
|
1145
|
+
...TRACK_CHANGES_INPUT_SHAPE,
|
|
1238
1146
|
...COMPLIANCE_PARAMS
|
|
1239
1147
|
}
|
|
1240
1148
|
}, withAuth("track_changes", async (params) => {
|
|
@@ -61,19 +61,33 @@ export function register(program) {
|
|
|
61
61
|
.option('--threshold <level>', 'Notification threshold: minor|moderate|major|critical', 'moderate')
|
|
62
62
|
.option('--cron <expr>', 'Optional cron expression (advanced)')
|
|
63
63
|
.option('--selector <css>', 'CSS selector to scope monitoring')
|
|
64
|
+
.option('--hosted', "Run the monitor on CrawlForge's servers (fires without this process; email + signed webhooks; 3 credits per compared target per check)")
|
|
65
|
+
.option('--email <addresses>', 'Comma-separated notification emails (sent by hosted monitors only)')
|
|
66
|
+
.option('--name <text>', 'Display name for a hosted monitor (default: the URL host)')
|
|
64
67
|
.action(async (url, opts) => {
|
|
65
68
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
69
|
+
const notificationOptions = {
|
|
70
|
+
...(opts.webhook ? { webhook: { enabled: true, url: opts.webhook } } : {}),
|
|
71
|
+
...(opts.email ? { email: { enabled: true, recipients: opts.email.split(',').map((s) => s.trim()).filter(Boolean) } } : {})
|
|
72
|
+
};
|
|
73
|
+
if (opts.email && !opts.hosted) {
|
|
74
|
+
process.stderr.write('Warning: local monitors do not send email; add --hosted for --email to take effect.\n');
|
|
75
|
+
}
|
|
66
76
|
try {
|
|
67
77
|
const res = await tool.execute({
|
|
68
78
|
url,
|
|
69
79
|
operation: 'create_scheduled_monitor',
|
|
70
80
|
...(opts.selector ? { trackingOptions: { customSelectors: [opts.selector] } } : {}),
|
|
71
|
-
...(
|
|
81
|
+
...(Object.keys(notificationOptions).length ? { notificationOptions } : {}),
|
|
72
82
|
scheduledMonitorOptions: {
|
|
73
83
|
interval: Math.max(parseInt(opts.every, 10), 60) * 1000,
|
|
74
84
|
...(opts.goal ? { goal: opts.goal } : {}),
|
|
75
85
|
...(opts.cron ? { schedule: opts.cron } : {}),
|
|
76
|
-
|
|
86
|
+
...(opts.hosted ? { hosted: true } : {}),
|
|
87
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
88
|
+
// Local only: a hosted check has no significance threshold, and
|
|
89
|
+
// the option's default would otherwise warn on every hosted create.
|
|
90
|
+
...(opts.hosted ? {} : { notificationThreshold: opts.threshold })
|
|
77
91
|
}
|
|
78
92
|
});
|
|
79
93
|
emit(res);
|
|
@@ -86,7 +100,7 @@ export function register(program) {
|
|
|
86
100
|
|
|
87
101
|
program
|
|
88
102
|
.command('monitor:list')
|
|
89
|
-
.description('List
|
|
103
|
+
.description('List scheduled monitors (local and hosted)')
|
|
90
104
|
.action(async () => {
|
|
91
105
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
92
106
|
try {
|
|
@@ -100,7 +114,7 @@ export function register(program) {
|
|
|
100
114
|
|
|
101
115
|
program
|
|
102
116
|
.command('monitor:stop <id>')
|
|
103
|
-
.description('Stop and remove a scheduled monitor by id')
|
|
117
|
+
.description('Stop and remove a scheduled monitor by id (local or hosted)')
|
|
104
118
|
.action(async (id) => {
|
|
105
119
|
const tool = new TrackChangesTool(getToolConfig('track_changes'));
|
|
106
120
|
try {
|
|
@@ -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
|
/**
|