crawlforge-mcp-server 5.2.9 → 5.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/CLAUDE.md +13 -1
- package/README.md +11 -9
- package/package.json +3 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +401 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +496 -0
- package/src/core/llm/OllamaProvider.js +14 -5
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/ollamaConfig.js +36 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -9,6 +9,63 @@ import { CacheManager } from './cache/CacheManager.js';
|
|
|
9
9
|
import { Logger } from '../utils/Logger.js';
|
|
10
10
|
import { LLMManager } from './llm/LLMManager.js';
|
|
11
11
|
import { safeFetch } from '../utils/ssrfGuard.js';
|
|
12
|
+
import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
|
|
13
|
+
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
14
|
+
import {
|
|
15
|
+
isAdmissibleClaim,
|
|
16
|
+
isVendorSelfPromotion,
|
|
17
|
+
isProductRecommendation
|
|
18
|
+
} from './research/claimFilters.js';
|
|
19
|
+
|
|
20
|
+
// Minimum per-source topical relevance for a claim to reach synthesis. Scores
|
|
21
|
+
// come from LLMManager.analyzeRelevance (0-1) or, when the LLM is unavailable,
|
|
22
|
+
// calculateTraditionalRelevance — which returns ~0 when none of the topic words
|
|
23
|
+
// appear in the content at all. 0.3 keeps loosely related pages and drops the
|
|
24
|
+
// ones a search phrase matched but the content does not discuss.
|
|
25
|
+
const MIN_CLAIM_RELEVANCE = 0.3;
|
|
26
|
+
|
|
27
|
+
// Minimum per-CLAIM topical relevance, from LLMManager.scoreClaimRelevance.
|
|
28
|
+
// Distinct from MIN_CLAIM_RELEVANCE above, which gates on the score of the
|
|
29
|
+
// whole source page: a page can be squarely on topic and still carry sentences
|
|
30
|
+
// that are not. Low, because admission is destructive — a rejected claim leaves
|
|
31
|
+
// the run entirely. Unscored claims are never filtered.
|
|
32
|
+
const MIN_CLAIM_TOPIC_RELEVANCE = 0.3;
|
|
33
|
+
|
|
34
|
+
// Higher bar for the findings that feed aiSummary. A claim can be worth
|
|
35
|
+
// reporting as evidence without being solid enough to draw a conclusion from,
|
|
36
|
+
// which is how a vendor's description of its own product ends up synthesized
|
|
37
|
+
// as a recommendation.
|
|
38
|
+
const MIN_SYNTHESIS_TOPIC_RELEVANCE = 0.5;
|
|
39
|
+
|
|
40
|
+
// A vendor's promotional claim about itself keeps its place in the evidence but
|
|
41
|
+
// stops competing with third-party analysis for a finding slot.
|
|
42
|
+
const VENDOR_PROMO_CREDIBILITY_FACTOR = 0.5;
|
|
43
|
+
|
|
44
|
+
// Share of key findings any single source may contribute. On the 2026-08-28
|
|
45
|
+
// live run all five findings came from one URL.
|
|
46
|
+
const MAX_FINDING_SHARE_PER_SOURCE = 0.4;
|
|
47
|
+
|
|
48
|
+
// deepResearch.js re-slices findings to 5 for outputFormat 'summary', so this
|
|
49
|
+
// many findings have to be diverse before depth matters.
|
|
50
|
+
const SUMMARY_SLICE = 5;
|
|
51
|
+
|
|
52
|
+
// Conflict detection runs only when the judging model is one measured not to
|
|
53
|
+
// invent disagreement. The default 4B local model, measured 2026-08-28 against
|
|
54
|
+
// a live run's own claims, named 29, 13 and 28 non-contradictions at batch
|
|
55
|
+
// sizes 30, 8 and 1; the consistency-veto control cut that to 7 but then
|
|
56
|
+
// missed "X does not use Y" against "X uses Y" outright. Replaying the same
|
|
57
|
+
// claims through gemma3:12b (three runs): 0 false contradictions on 27 real
|
|
58
|
+
// pairs and every planted one caught. So the gate is the model, not a flag:
|
|
59
|
+
// LLMManager.canJudgeContradictions() answers from JUDGEMENT_MODELS, and a
|
|
60
|
+
// machine without such a model reports zero conflicts — the honest answer,
|
|
61
|
+
// since a research tool that invents contradictions between sources that
|
|
62
|
+
// agree is worse than one that reports none.
|
|
63
|
+
|
|
64
|
+
// Contradiction checking is quadratic in a group's size, and every candidate
|
|
65
|
+
// pair costs prompt tokens in the one batched call. Compare a group's most
|
|
66
|
+
// credible claims only, and cap the batch.
|
|
67
|
+
const MAX_CONFLICT_CLAIMS_PER_GROUP = 6;
|
|
68
|
+
const MAX_CONFLICT_PAIRS = 40;
|
|
12
69
|
|
|
13
70
|
/**
|
|
14
71
|
* ResearchOrchestrator - Multi-stage research orchestration engine with LLM integration
|
|
@@ -677,10 +734,16 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
677
734
|
});
|
|
678
735
|
// Fallback: use fetch + basic text extraction
|
|
679
736
|
try {
|
|
737
|
+
// Same gate the primary extract path goes through — the
|
|
738
|
+
// fallback must not become a way around robots.txt.
|
|
739
|
+
const gate = await preflightFetch(source.link, { tool: 'deep_research' });
|
|
680
740
|
const fetchResponse = await safeFetch(source.link, {
|
|
681
|
-
headers: {
|
|
741
|
+
headers: { ...gate.headers },
|
|
682
742
|
signal: AbortSignal.timeout(10000)
|
|
683
743
|
});
|
|
744
|
+
if (fetchResponse.status === 429 || fetchResponse.status === 503) {
|
|
745
|
+
noteRetryAfter(source.link, fetchResponse.headers.get('retry-after'));
|
|
746
|
+
}
|
|
684
747
|
if (fetchResponse.ok) {
|
|
685
748
|
const html = await fetchResponse.text();
|
|
686
749
|
// Strip HTML tags for basic text content
|
|
@@ -897,6 +960,12 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
897
960
|
* (proven), so it gets a single attempt to avoid burning the time budget.
|
|
898
961
|
*/
|
|
899
962
|
async _stealthFetchHtml(url) {
|
|
963
|
+
// The HTTP path gates at the fetch; this fallback drives a browser
|
|
964
|
+
// straight past it, so it has to gate too — otherwise "the page blocked
|
|
965
|
+
// us" becomes a route around robots.txt. Before _getStealthBrowser(), so
|
|
966
|
+
// a disallowed URL never launches one.
|
|
967
|
+
await browserPreflight(url, { tool: 'deep_research' });
|
|
968
|
+
|
|
900
969
|
await this._getStealthBrowser();
|
|
901
970
|
const attempts = this._stealthEngineActive === 'camoufox' ? 3 : 1;
|
|
902
971
|
for (let i = 0; i < attempts; i++) {
|
|
@@ -1041,14 +1110,14 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1041
1110
|
}
|
|
1042
1111
|
|
|
1043
1112
|
// Extract key claims and facts from each source
|
|
1044
|
-
const extractedClaims = await this.extractKeyClaims(sources);
|
|
1045
|
-
|
|
1113
|
+
const extractedClaims = await this.extractKeyClaims(sources, topic);
|
|
1114
|
+
|
|
1046
1115
|
// Group related claims
|
|
1047
|
-
const claimGroups = this.groupRelatedClaims(extractedClaims);
|
|
1116
|
+
const claimGroups = await this.groupRelatedClaims(extractedClaims, topic);
|
|
1048
1117
|
|
|
1049
1118
|
// Detect conflicts between claims
|
|
1050
1119
|
if (this.enableConflictDetection) {
|
|
1051
|
-
synthesis.conflicts = this.detectInformationConflicts(claimGroups);
|
|
1120
|
+
synthesis.conflicts = await this.detectInformationConflicts(claimGroups, topic);
|
|
1052
1121
|
this.metrics.conflictsDetected = synthesis.conflicts.length;
|
|
1053
1122
|
}
|
|
1054
1123
|
|
|
@@ -1072,12 +1141,24 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1072
1141
|
try {
|
|
1073
1142
|
this.logger.info('Generating LLM-powered research synthesis');
|
|
1074
1143
|
|
|
1075
|
-
// Prepare findings for LLM analysis
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1144
|
+
// Prepare findings for LLM analysis. A vendor's promotional claim
|
|
1145
|
+
// about its own product is not a research conclusion — the synthesis
|
|
1146
|
+
// otherwise recommends whichever vendor's page ranked best. A finding
|
|
1147
|
+
// the LLM scored as only loosely about the topic is withheld for the
|
|
1148
|
+
// same reason: it stays in the reported evidence, but it is not
|
|
1149
|
+
// material to conclude from. Only withheld while something else
|
|
1150
|
+
// remains to synthesize.
|
|
1151
|
+
const conclusive = synthesis.keyFindings.filter(finding =>
|
|
1152
|
+
!finding.promotional &&
|
|
1153
|
+
(typeof finding.topicRelevance !== 'number' ||
|
|
1154
|
+
finding.topicRelevance >= MIN_SYNTHESIS_TOPIC_RELEVANCE)
|
|
1155
|
+
);
|
|
1156
|
+
const findingsForLLM = (conclusive.length > 0 ? conclusive : synthesis.keyFindings)
|
|
1157
|
+
.map(finding => ({
|
|
1158
|
+
finding: finding.finding,
|
|
1159
|
+
credibility: finding.credibility,
|
|
1160
|
+
sources: finding.sources.length
|
|
1161
|
+
}));
|
|
1081
1162
|
|
|
1082
1163
|
const llmSynthesis = await this.llmManager.synthesizeFindings(
|
|
1083
1164
|
findingsForLLM,
|
|
@@ -1124,7 +1205,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1124
1205
|
/**
|
|
1125
1206
|
* Extract key claims from source content
|
|
1126
1207
|
*/
|
|
1127
|
-
async extractKeyClaims(sources) {
|
|
1208
|
+
async extractKeyClaims(sources, topic) {
|
|
1128
1209
|
const claims = [];
|
|
1129
1210
|
|
|
1130
1211
|
for (const source of sources) {
|
|
@@ -1146,13 +1227,22 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1146
1227
|
// Handle both keypoints (tool output) and keyPoints (legacy) property names
|
|
1147
1228
|
const keyPoints = summary.keypoints || summary.keyPoints || [];
|
|
1148
1229
|
if (keyPoints.length > 0) {
|
|
1230
|
+
const relevance = this.sourceRelevance(source);
|
|
1149
1231
|
keyPoints.forEach((point, index) => {
|
|
1232
|
+
const credibility = source.overallCredibility || 0.65;
|
|
1233
|
+
// A recommendation is not evidence. Two routes to the same flag: a
|
|
1234
|
+
// page promoting itself, and — whoever published it — a claim whose
|
|
1235
|
+
// subject is a named offering credited with doing the work.
|
|
1236
|
+
const promotional = isVendorSelfPromotion(point, source.link) ||
|
|
1237
|
+
isProductRecommendation(point);
|
|
1150
1238
|
claims.push({
|
|
1151
1239
|
id: `${source.link}_claim_${index}`,
|
|
1152
1240
|
claim: point,
|
|
1153
1241
|
source: source.link,
|
|
1154
1242
|
sourceTitle: source.title,
|
|
1155
|
-
credibility:
|
|
1243
|
+
credibility: promotional ? credibility * VENDOR_PROMO_CREDIBILITY_FACTOR : credibility,
|
|
1244
|
+
relevance,
|
|
1245
|
+
promotional,
|
|
1156
1246
|
context: summary.supporting?.[index] || '',
|
|
1157
1247
|
extractedAt: new Date().toISOString()
|
|
1158
1248
|
});
|
|
@@ -1166,93 +1256,255 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1166
1256
|
}
|
|
1167
1257
|
}
|
|
1168
1258
|
|
|
1169
|
-
|
|
1259
|
+
await this.scoreClaimTopicRelevance(claims, topic);
|
|
1260
|
+
|
|
1261
|
+
return this.admitClaims(claims);
|
|
1170
1262
|
}
|
|
1171
1263
|
|
|
1172
1264
|
/**
|
|
1173
|
-
*
|
|
1265
|
+
* Record how much each individual claim is about the research topic.
|
|
1266
|
+
*
|
|
1267
|
+
* One batched LLM call for the whole run. The score lands on a separate
|
|
1268
|
+
* `topicRelevance` field, and only when the model actually scored it: an
|
|
1269
|
+
* entry may be null for a claim it skipped, which leaves that claim
|
|
1270
|
+
* unscored and therefore unfiltered. `relevance` is the source page's and
|
|
1271
|
+
* means something different. Claims are left unscored — and therefore
|
|
1272
|
+
* unfiltered — whenever the LLM cannot answer, so a failure here reproduces
|
|
1273
|
+
* the behaviour of not having asked.
|
|
1174
1274
|
*/
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1275
|
+
async scoreClaimTopicRelevance(claims, topic) {
|
|
1276
|
+
if (!this.enableLLMFeatures || !topic || claims.length === 0) return;
|
|
1277
|
+
|
|
1278
|
+
try {
|
|
1279
|
+
const scores = await this.llmManager.scoreClaimRelevance(
|
|
1280
|
+
claims.map(claim => claim.claim),
|
|
1281
|
+
topic
|
|
1282
|
+
);
|
|
1283
|
+
this.metrics.llmAnalysisCalls++;
|
|
1284
|
+
|
|
1285
|
+
if (!Array.isArray(scores) || scores.length !== claims.length) return;
|
|
1286
|
+
|
|
1287
|
+
claims.forEach((claim, index) => {
|
|
1288
|
+
// null marks a claim the model did not score, and stays unscored —
|
|
1289
|
+
// never 0, which would drop it. Number.isFinite rather than a typeof
|
|
1290
|
+
// check because typeof NaN is 'number', and a recorded NaN fails every
|
|
1291
|
+
// >= comparison below, silently rejecting a good claim.
|
|
1292
|
+
if (Number.isFinite(scores[index])) claim.topicRelevance = scores[index];
|
|
1293
|
+
});
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
this.logger.warn('Claim relevance scoring failed', { error: error.message });
|
|
1193
1296
|
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Topical relevance recorded for a source during deep exploration, or
|
|
1301
|
+
* undefined when none was computed (extraction failed before analysis).
|
|
1302
|
+
*/
|
|
1303
|
+
sourceRelevance(source) {
|
|
1304
|
+
if (typeof source.relevanceScore === 'number') return source.relevanceScore;
|
|
1305
|
+
return this.researchState?.relevanceScores?.get(source.link);
|
|
1306
|
+
}
|
|
1194
1307
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1308
|
+
/**
|
|
1309
|
+
* Keep only claims that can be research findings: prose about the topic, not
|
|
1310
|
+
* document front matter (author/affiliation blocks, DOI stubs, "Retrieved
|
|
1311
|
+
* from" lines), not from a source the relevance analysis scored below
|
|
1312
|
+
* MIN_CLAIM_RELEVANCE, and not a sentence the LLM scored as barely about the
|
|
1313
|
+
* topic. Each gate falls back to the previous claim set rather than returning
|
|
1314
|
+
* nothing — no claims means no findings at all.
|
|
1315
|
+
*/
|
|
1316
|
+
admitClaims(claims) {
|
|
1317
|
+
if (claims.length === 0) return claims;
|
|
1318
|
+
|
|
1319
|
+
const substantive = claims.filter(claim => isAdmissibleClaim(claim.claim));
|
|
1320
|
+
const relevant = substantive.filter(
|
|
1321
|
+
claim => typeof claim.relevance !== 'number' || claim.relevance >= MIN_CLAIM_RELEVANCE
|
|
1322
|
+
);
|
|
1323
|
+
const onTopic = relevant.filter(
|
|
1324
|
+
claim => typeof claim.topicRelevance !== 'number' ||
|
|
1325
|
+
claim.topicRelevance >= MIN_CLAIM_TOPIC_RELEVANCE
|
|
1326
|
+
);
|
|
1327
|
+
|
|
1328
|
+
const admitted = onTopic.length > 0
|
|
1329
|
+
? onTopic
|
|
1330
|
+
: (relevant.length > 0 ? relevant : (substantive.length > 0 ? substantive : claims));
|
|
1331
|
+
|
|
1332
|
+
this.logger.debug('Claim admission', {
|
|
1333
|
+
candidates: claims.length,
|
|
1334
|
+
substantive: substantive.length,
|
|
1335
|
+
relevant: relevant.length,
|
|
1336
|
+
onTopic: onTopic.length,
|
|
1337
|
+
admitted: admitted.length
|
|
1199
1338
|
});
|
|
1200
1339
|
|
|
1201
|
-
return
|
|
1340
|
+
return admitted;
|
|
1202
1341
|
}
|
|
1203
1342
|
|
|
1204
1343
|
/**
|
|
1205
|
-
*
|
|
1344
|
+
* Group related claims for analysis.
|
|
1345
|
+
*
|
|
1346
|
+
* Semantically when the LLM can partition them, otherwise by the claim's own
|
|
1347
|
+
* first-three-sorted keywords. The keyword key splits paraphrases — measured
|
|
1348
|
+
* on 27 claims from a live run it produced 27 groups, none with more than one
|
|
1349
|
+
* claim, which makes consensus (needs sourceCount >= 2) and conflict
|
|
1350
|
+
* detection (needs two claims in a group) structurally unreachable.
|
|
1206
1351
|
*/
|
|
1207
|
-
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1352
|
+
async groupRelatedClaims(claims, topic) {
|
|
1353
|
+
const semantic = await this.semanticClaimGroups(claims, topic);
|
|
1354
|
+
if (semantic) return semantic;
|
|
1355
|
+
|
|
1356
|
+
const byKeywordKey = new Map();
|
|
1357
|
+
|
|
1358
|
+
for (const claim of claims) {
|
|
1359
|
+
const groupKey = this.extractKeywords(claim.claim).slice(0, 3).sort().join('_');
|
|
1360
|
+
if (!byKeywordKey.has(groupKey)) byKeywordKey.set(groupKey, []);
|
|
1361
|
+
byKeywordKey.get(groupKey).push(claim);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return Array.from(byKeywordKey, ([key, grouped]) => this.buildClaimGroup(key, grouped));
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Groups from the LLM's semantic partition, or null to use the keyword key.
|
|
1369
|
+
*
|
|
1370
|
+
* Null on every path the contract calls a failure: LLM features off, no
|
|
1371
|
+
* topic, an empty partition. A partition that does not cover the claims
|
|
1372
|
+
* exactly once would silently drop evidence from the run, so that falls back
|
|
1373
|
+
* too rather than being trusted.
|
|
1374
|
+
*/
|
|
1375
|
+
async semanticClaimGroups(claims, topic) {
|
|
1376
|
+
if (!this.enableLLMFeatures || !topic || claims.length === 0) return null;
|
|
1377
|
+
|
|
1378
|
+
let partition;
|
|
1379
|
+
try {
|
|
1380
|
+
partition = await this.llmManager.groupClaimsBySimilarity(
|
|
1381
|
+
claims.map(claim => claim.claim),
|
|
1382
|
+
topic
|
|
1383
|
+
);
|
|
1384
|
+
this.metrics.llmAnalysisCalls++;
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
this.logger.warn('Semantic claim grouping failed', { error: error.message });
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if (!Array.isArray(partition)) return null;
|
|
1391
|
+
if (!partition.every(group => Array.isArray(group) && group.length > 0)) return null;
|
|
1392
|
+
|
|
1393
|
+
const indices = partition.flat();
|
|
1394
|
+
const isPartition = indices.length === claims.length &&
|
|
1395
|
+
new Set(indices).size === claims.length &&
|
|
1396
|
+
indices.every(i => Number.isInteger(i) && i >= 0 && i < claims.length);
|
|
1397
|
+
if (!isPartition) return null;
|
|
1398
|
+
|
|
1399
|
+
return partition.map((group, index) =>
|
|
1400
|
+
this.buildClaimGroup(`semantic_${index}`, group.map(i => claims[i]))
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* A claim group and its statistics. Both grouping paths build groups here so
|
|
1406
|
+
* sourceCount and avgCredibility cannot diverge between them.
|
|
1407
|
+
*/
|
|
1408
|
+
buildClaimGroup(id, claims) {
|
|
1409
|
+
return {
|
|
1410
|
+
id,
|
|
1411
|
+
keywords: this.extractKeywords(claims[0].claim),
|
|
1412
|
+
claims,
|
|
1413
|
+
sourceCount: new Set(claims.map(c => c.source)).size,
|
|
1414
|
+
avgCredibility: claims.reduce((sum, c) => sum + c.credibility, 0) / claims.length
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Detect conflicts between information claims.
|
|
1420
|
+
*
|
|
1421
|
+
* Only the LLM decides. Candidate pairs are drawn from within a semantic
|
|
1422
|
+
* group — claims already judged to be about the same thing — and the whole
|
|
1423
|
+
* batch goes to the model in one call; a pair becomes a conflict only if the
|
|
1424
|
+
* model names it.
|
|
1425
|
+
*
|
|
1426
|
+
* Fails CLOSED, in every sense: no LLM, an error, an unusable answer, or
|
|
1427
|
+
* nothing found all report zero conflicts. Zero is an honest answer. Two
|
|
1428
|
+
* lexical detectors have now been tried and both produced pure noise — the
|
|
1429
|
+
* second reported 42 conflicts on a live run, none of them real, because
|
|
1430
|
+
* extractive claims are long multi-sentence blobs and nearly every pair
|
|
1431
|
+
* contains both a negation and an affirmation somewhere. There is no
|
|
1432
|
+
* sentence-shape repair for that, so there is no fallback path here.
|
|
1433
|
+
*/
|
|
1434
|
+
async detectInformationConflicts(claimGroups, topic) {
|
|
1435
|
+
if (!this.enableLLMFeatures) return [];
|
|
1436
|
+
if (!(await this.llmManager.canJudgeContradictions())) return [];
|
|
1437
|
+
|
|
1438
|
+
const pairs = [];
|
|
1210
1439
|
for (const group of claimGroups) {
|
|
1211
1440
|
if (group.claims.length < 2) continue;
|
|
1212
|
-
|
|
1213
|
-
//
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const text1 = claim1.claim.toLowerCase();
|
|
1225
|
-
const text2 = claim2.claim.toLowerCase();
|
|
1226
|
-
|
|
1227
|
-
for (const [neg, pos] of conflictIndicators) {
|
|
1228
|
-
if ((text1.includes(neg) && text2.includes(pos)) ||
|
|
1229
|
-
(text1.includes(pos) && text2.includes(neg))) {
|
|
1230
|
-
|
|
1231
|
-
conflicts.push({
|
|
1232
|
-
id: `conflict_${conflicts.length}`,
|
|
1233
|
-
type: 'contradiction',
|
|
1234
|
-
claim1: claim1,
|
|
1235
|
-
claim2: claim2,
|
|
1236
|
-
severity: this.calculateConflictSeverity(claim1, claim2),
|
|
1237
|
-
detectedAt: new Date().toISOString()
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
break;
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1441
|
+
|
|
1442
|
+
// Pairs grow quadratically, so compare only a group's most credible
|
|
1443
|
+
// claims and bound the batch overall — this runs inside the tool's
|
|
1444
|
+
// wall-clock limit.
|
|
1445
|
+
const claims = [...group.claims]
|
|
1446
|
+
.sort((a, b) => (b.credibility || 0) - (a.credibility || 0))
|
|
1447
|
+
.slice(0, MAX_CONFLICT_CLAIMS_PER_GROUP);
|
|
1448
|
+
|
|
1449
|
+
for (let i = 0; i < claims.length; i++) {
|
|
1450
|
+
for (let j = i + 1; j < claims.length; j++) {
|
|
1451
|
+
pairs.push({ a: claims[i], b: claims[j] });
|
|
1243
1452
|
}
|
|
1244
1453
|
}
|
|
1245
1454
|
}
|
|
1246
1455
|
|
|
1247
|
-
|
|
1456
|
+
const candidates = pairs.slice(0, MAX_CONFLICT_PAIRS);
|
|
1457
|
+
if (candidates.length === 0) return [];
|
|
1458
|
+
|
|
1459
|
+
let contradicting;
|
|
1460
|
+
try {
|
|
1461
|
+
contradicting = await this.llmManager.findContradictions(
|
|
1462
|
+
candidates.map(({ a, b }) => ({ a: a.claim, b: b.claim })),
|
|
1463
|
+
topic,
|
|
1464
|
+
// The judge's own default examined 30; every candidate formed here is
|
|
1465
|
+
// meant to be judged, so the caps agree.
|
|
1466
|
+
{ maxPairs: MAX_CONFLICT_PAIRS }
|
|
1467
|
+
);
|
|
1468
|
+
this.metrics.llmAnalysisCalls++;
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
this.logger.warn('Contradiction detection failed', { error: error.message });
|
|
1471
|
+
return [];
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (!Array.isArray(contradicting)) return [];
|
|
1475
|
+
|
|
1476
|
+
return contradicting
|
|
1477
|
+
.filter(index => Number.isInteger(index) && index >= 0 && index < candidates.length)
|
|
1478
|
+
.map((pairIndex, position) => {
|
|
1479
|
+
const { a, b } = candidates[pairIndex];
|
|
1480
|
+
return {
|
|
1481
|
+
id: `conflict_${position}`,
|
|
1482
|
+
type: 'contradiction',
|
|
1483
|
+
claim1: a,
|
|
1484
|
+
claim2: b,
|
|
1485
|
+
severity: this.calculateConflictSeverity(a, b),
|
|
1486
|
+
detectedAt: new Date().toISOString()
|
|
1487
|
+
};
|
|
1488
|
+
});
|
|
1248
1489
|
}
|
|
1249
1490
|
|
|
1250
1491
|
/**
|
|
1251
|
-
* Identify areas of consensus
|
|
1492
|
+
* Identify areas of consensus.
|
|
1493
|
+
*
|
|
1494
|
+
* Corroboration is the load-bearing requirement: two independent sources
|
|
1495
|
+
* saying the same thing. The credibility floor is the tool's own
|
|
1496
|
+
* `credibilityThreshold` (default 0.3, caller-settable, and already what
|
|
1497
|
+
* generateKeyFindings and compileSupportingEvidence use) rather than a
|
|
1498
|
+
* separate hardcoded 0.6, which gated consensus out entirely on real
|
|
1499
|
+
* sources. Measured on the live 2026-08-28 run: source credibility spanned
|
|
1500
|
+
* 0.496-0.630 (n=7, avg 0.567) and only one of four findings cleared 0.6,
|
|
1501
|
+
* with VENDOR_PROMO_CREDIBILITY_FACTOR pulling promotional groups lower
|
|
1502
|
+
* still. The floor now only excludes what the caller already considers too
|
|
1503
|
+
* weak to be a finding at all.
|
|
1252
1504
|
*/
|
|
1253
1505
|
identifyConsensus(claimGroups) {
|
|
1254
1506
|
return claimGroups
|
|
1255
|
-
.filter(group => group.sourceCount >= 2 && group.avgCredibility >=
|
|
1507
|
+
.filter(group => group.sourceCount >= 2 && group.avgCredibility >= this.credibilityThreshold)
|
|
1256
1508
|
.map(group => ({
|
|
1257
1509
|
topic: this.claimGroupLabel(group),
|
|
1258
1510
|
supportingClaims: group.claims.length,
|
|
@@ -1531,16 +1783,79 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1531
1783
|
}
|
|
1532
1784
|
|
|
1533
1785
|
generateKeyFindings(claimGroups, sources) {
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1786
|
+
const limit = 10;
|
|
1787
|
+
const eligible = claimGroups.filter(group => group.avgCredibility >= this.credibilityThreshold);
|
|
1788
|
+
|
|
1789
|
+
// With a single reachable source there is nothing to diversify, so the
|
|
1790
|
+
// per-source cap only applies once findings could come from more than one.
|
|
1791
|
+
const distinctSources = new Set(eligible.flatMap(g => g.claims.map(c => c.source)));
|
|
1792
|
+
const perSourceCap = distinctSources.size > 1
|
|
1793
|
+
? Math.max(1, Math.ceil(limit * MAX_FINDING_SHARE_PER_SOURCE))
|
|
1794
|
+
: limit;
|
|
1795
|
+
|
|
1796
|
+
const ranked = eligible
|
|
1797
|
+
// groupRelatedClaims never sets consensusStrength, so the sort below used
|
|
1798
|
+
// to compare undefined with undefined and order nothing.
|
|
1799
|
+
.map(group => ({ group, strength: group.consensusStrength ?? this.calculateConsensusStrength(group) }))
|
|
1800
|
+
.sort((a, b) => {
|
|
1801
|
+
// A corroborated group (more than one supporting claim) outranks a
|
|
1802
|
+
// lone claim regardless of strength.
|
|
1803
|
+
const corroboration = Number(b.group.claims.length > 1) - Number(a.group.claims.length > 1);
|
|
1804
|
+
return corroboration !== 0 ? corroboration : b.strength - a.strength;
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
// Queue the ranked groups per source that supplies the surfaced claim, then
|
|
1808
|
+
// take one from each queue per round. Diversity has to hold at the TOP of
|
|
1809
|
+
// the list, not only in aggregate: deepResearch.js re-slices findings to 5
|
|
1810
|
+
// for outputFormat 'summary', and an aggregate cap of 4-in-10 still allows
|
|
1811
|
+
// 4 of the first 5 to come from one URL. Interleaving guarantees a
|
|
1812
|
+
// positional property instead — a source contributes a second finding only
|
|
1813
|
+
// after every other source with findings left has contributed one — while
|
|
1814
|
+
// the per-source cap bounds a source that outlasts all the others.
|
|
1815
|
+
const queues = new Map(); // insertion order: strongest source first
|
|
1816
|
+
for (const { group } of ranked) {
|
|
1817
|
+
const claim = this.mostCredibleClaim(group);
|
|
1818
|
+
if (!queues.has(claim.source)) queues.set(claim.source, []);
|
|
1819
|
+
queues.get(claim.source).push({ group, claim });
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Interleave the strongest SUMMARY_SLICE sources first and only widen to
|
|
1823
|
+
// the rest once those queues run dry. Interleaving every source instead
|
|
1824
|
+
// measurably degraded the research (live 2026-08-28): with ten thin sources
|
|
1825
|
+
// it spent all ten slots on one line each — including a bot-check
|
|
1826
|
+
// interstitial — and pushed the sources that actually covered the topic
|
|
1827
|
+
// down to a single claim apiece.
|
|
1828
|
+
const ordered = Array.from(queues.values());
|
|
1829
|
+
const findings = [];
|
|
1830
|
+
|
|
1831
|
+
for (const pool of [ordered.slice(0, SUMMARY_SLICE), ordered.slice(SUMMARY_SLICE)]) {
|
|
1832
|
+
for (let round = 0; findings.length < limit && round < perSourceCap; round++) {
|
|
1833
|
+
let advanced = false;
|
|
1834
|
+
|
|
1835
|
+
for (const queue of pool) {
|
|
1836
|
+
if (round >= queue.length) continue;
|
|
1837
|
+
advanced = true;
|
|
1838
|
+
|
|
1839
|
+
const { group, claim } = queue[round];
|
|
1840
|
+
findings.push({
|
|
1841
|
+
finding: claim.claim,
|
|
1842
|
+
supportingClaims: group.claims.length,
|
|
1843
|
+
credibility: group.avgCredibility,
|
|
1844
|
+
sources: group.claims.map(c => c.source),
|
|
1845
|
+
...(claim.promotional ? { promotional: true } : {}),
|
|
1846
|
+
...(typeof claim.topicRelevance === 'number'
|
|
1847
|
+
? { topicRelevance: claim.topicRelevance }
|
|
1848
|
+
: {})
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
if (findings.length >= limit) break;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (!advanced) break;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
return findings;
|
|
1544
1859
|
}
|
|
1545
1860
|
|
|
1546
1861
|
compileSupportingEvidence(sources) {
|