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.
Files changed (73) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +11 -9
  3. package/package.json +3 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +401 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +496 -0
  20. package/src/core/llm/OllamaProvider.js +14 -5
  21. package/src/core/processing/BrowserProcessor.js +27 -0
  22. package/src/core/processing/ContentProcessor.js +11 -39
  23. package/src/core/processing/PDFProcessor.js +2 -3
  24. package/src/core/research/claimFilters.js +235 -0
  25. package/src/schemas/toolOutputSchemas.js +5 -1
  26. package/src/security/wave3-security.js +2 -1
  27. package/src/server/requestContext.js +23 -0
  28. package/src/server/withAuth.js +21 -5
  29. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -1
  30. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  31. package/src/tools/advanced/batchScrape/schema.js +4 -0
  32. package/src/tools/advanced/batchScrape/worker.js +19 -10
  33. package/src/tools/basic/_fetch.js +19 -15
  34. package/src/tools/basic/extractLinks.js +8 -3
  35. package/src/tools/basic/extractMetadata.js +7 -3
  36. package/src/tools/basic/extractText.js +8 -3
  37. package/src/tools/basic/fetchUrl.js +7 -3
  38. package/src/tools/basic/scrapeStructured.js +76 -3
  39. package/src/tools/crawl/_sessionContext.js +10 -2
  40. package/src/tools/crawl/crawlDeep.js +29 -12
  41. package/src/tools/crawl/mapSite.js +39 -14
  42. package/src/tools/extract/_fetchAndParse.js +23 -8
  43. package/src/tools/extract/analyzeContent.js +5 -3
  44. package/src/tools/extract/extractContent.js +18 -4
  45. package/src/tools/extract/extractStructured.js +66 -12
  46. package/src/tools/extract/extractWithLlm.js +51 -4
  47. package/src/tools/extract/processDocument.js +45 -78
  48. package/src/tools/extract/summarizeContent.js +35 -1
  49. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  50. package/src/tools/research/deepResearch.js +2 -1
  51. package/src/tools/scrape/_brandingExtractor.js +42 -3
  52. package/src/tools/scrape/_mainContent.js +105 -0
  53. package/src/tools/scrape/unifiedScrape.js +21 -14
  54. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  55. package/src/tools/search/redditSearch.js +6 -3
  56. package/src/tools/search/searchWeb.js +26 -3
  57. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  58. package/src/tools/tracking/trackChanges/differ.js +26 -3
  59. package/src/tools/tracking/trackChanges/index.js +12 -5
  60. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  61. package/src/tools/tracking/trackChanges/schema.js +3 -0
  62. package/src/utils/complianceAudit.js +72 -0
  63. package/src/utils/contentUtils.js +12 -1
  64. package/src/utils/domainFilter.js +38 -19
  65. package/src/utils/fetchIdentity.js +62 -0
  66. package/src/utils/hostBlocklist.js +81 -0
  67. package/src/utils/hostRateLimiter.js +101 -2
  68. package/src/utils/ollamaConfig.js +36 -2
  69. package/src/utils/robotsChecker.js +90 -43
  70. package/src/utils/robotsGate.js +206 -0
  71. package/src/utils/sitemapParser.js +33 -15
  72. package/src/utils/ssrfProtection.js +2 -1
  73. 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: { 'User-Agent': 'CrawlForge-Research/1.0' },
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
- const findingsForLLM = synthesis.keyFindings.map(finding => ({
1077
- finding: finding.finding,
1078
- credibility: finding.credibility,
1079
- sources: finding.sources.length
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: source.overallCredibility || 0.65,
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
- return claims;
1259
+ await this.scoreClaimTopicRelevance(claims, topic);
1260
+
1261
+ return this.admitClaims(claims);
1170
1262
  }
1171
1263
 
1172
1264
  /**
1173
- * Group related claims for analysis
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
- groupRelatedClaims(claims) {
1176
- const groups = new Map();
1177
-
1178
- for (const claim of claims) {
1179
- const keywords = this.extractKeywords(claim.claim);
1180
- const groupKey = keywords.slice(0, 3).sort().join('_');
1181
-
1182
- if (!groups.has(groupKey)) {
1183
- groups.set(groupKey, {
1184
- id: groupKey,
1185
- keywords,
1186
- claims: [],
1187
- avgCredibility: 0,
1188
- sourceCount: 0
1189
- });
1190
- }
1191
-
1192
- groups.get(groupKey).claims.push(claim);
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
- // Calculate group statistics
1196
- groups.forEach(group => {
1197
- group.sourceCount = new Set(group.claims.map(c => c.source)).size;
1198
- group.avgCredibility = group.claims.reduce((sum, c) => sum + c.credibility, 0) / group.claims.length;
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 Array.from(groups.values());
1340
+ return admitted;
1202
1341
  }
1203
1342
 
1204
1343
  /**
1205
- * Detect conflicts between information claims
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
- detectInformationConflicts(claimGroups) {
1208
- const conflicts = [];
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
- // Simple conflict detection based on contradictory terms
1214
- const conflictIndicators = [
1215
- ['not', 'is'], ['false', 'true'], ['incorrect', 'correct'],
1216
- ['impossible', 'possible'], ['never', 'always'], ['no', 'yes']
1217
- ];
1218
-
1219
- for (let i = 0; i < group.claims.length; i++) {
1220
- for (let j = i + 1; j < group.claims.length; j++) {
1221
- const claim1 = group.claims[i];
1222
- const claim2 = group.claims[j];
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
- return conflicts;
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 >= 0.6)
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
- return claimGroups
1535
- .filter(group => group.avgCredibility >= this.credibilityThreshold)
1536
- .sort((a, b) => b.consensusStrength - a.consensusStrength)
1537
- .slice(0, 10)
1538
- .map(group => ({
1539
- finding: this.mostCredibleClaim(group).claim,
1540
- supportingClaims: group.claims.length,
1541
- credibility: group.avgCredibility,
1542
- sources: group.claims.map(c => c.source)
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) {