crawlforge-mcp-server 5.2.9 → 5.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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -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 +407 -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 +473 -0
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. package/src/utils/webBotAuth.js +193 -0
@@ -9,6 +9,72 @@ 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
+ // Pairwise contradiction judgement is OFF, and the reason is measured rather
53
+ // than assumed. Against a live run's own claims (2026-08-28) the default local
54
+ // model returned 29, 13 and 28 false contradictions at batch sizes 30, 8 and 1
55
+ // — one pair per call being the worst, because with nothing to compare against
56
+ // it affirms whatever it is shown. That is acquiescence bias, a documented and
57
+ // general LLM failure mode. Adding the standard control for it (asking which
58
+ // pairs are CONSISTENT and vetoing anything named by both passes) cut false
59
+ // positives to 7 but then missed a direct negation pair outright — "X does not
60
+ // use Y" against "X uses Y" was called consistent. At that point the signal is
61
+ // anti-correlated with the truth.
62
+ //
63
+ // Zero conflicts is the honest answer: a research tool that invents
64
+ // contradictions between sources that agree is worse than one that reports
65
+ // none. Semantic grouping (which this replaced the lexical key with) DID make
66
+ // detection structurally reachable — that half of the question is answered —
67
+ // and consensus, which needed the same grouping, now works. The judgement
68
+ // itself lives in LLMManager.findContradictions, is unit-tested, and becomes
69
+ // useful the moment a model that can do natural-language inference is wired
70
+ // in; a purpose-built NLI cross-encoder is the documented next step.
71
+ const ENABLE_LLM_CONFLICT_DETECTION = false;
72
+
73
+ // Contradiction checking is quadratic in a group's size, and every candidate
74
+ // pair costs prompt tokens in the one batched call. Compare a group's most
75
+ // credible claims only, and cap the batch.
76
+ const MAX_CONFLICT_CLAIMS_PER_GROUP = 6;
77
+ const MAX_CONFLICT_PAIRS = 40;
12
78
 
13
79
  /**
14
80
  * ResearchOrchestrator - Multi-stage research orchestration engine with LLM integration
@@ -677,10 +743,16 @@ export class ResearchOrchestrator extends EventEmitter {
677
743
  });
678
744
  // Fallback: use fetch + basic text extraction
679
745
  try {
746
+ // Same gate the primary extract path goes through — the
747
+ // fallback must not become a way around robots.txt.
748
+ const gate = await preflightFetch(source.link, { tool: 'deep_research' });
680
749
  const fetchResponse = await safeFetch(source.link, {
681
- headers: { 'User-Agent': 'CrawlForge-Research/1.0' },
750
+ headers: { ...gate.headers },
682
751
  signal: AbortSignal.timeout(10000)
683
752
  });
753
+ if (fetchResponse.status === 429 || fetchResponse.status === 503) {
754
+ noteRetryAfter(source.link, fetchResponse.headers.get('retry-after'));
755
+ }
684
756
  if (fetchResponse.ok) {
685
757
  const html = await fetchResponse.text();
686
758
  // Strip HTML tags for basic text content
@@ -897,6 +969,12 @@ export class ResearchOrchestrator extends EventEmitter {
897
969
  * (proven), so it gets a single attempt to avoid burning the time budget.
898
970
  */
899
971
  async _stealthFetchHtml(url) {
972
+ // The HTTP path gates at the fetch; this fallback drives a browser
973
+ // straight past it, so it has to gate too — otherwise "the page blocked
974
+ // us" becomes a route around robots.txt. Before _getStealthBrowser(), so
975
+ // a disallowed URL never launches one.
976
+ await browserPreflight(url, { tool: 'deep_research' });
977
+
900
978
  await this._getStealthBrowser();
901
979
  const attempts = this._stealthEngineActive === 'camoufox' ? 3 : 1;
902
980
  for (let i = 0; i < attempts; i++) {
@@ -1041,14 +1119,14 @@ export class ResearchOrchestrator extends EventEmitter {
1041
1119
  }
1042
1120
 
1043
1121
  // Extract key claims and facts from each source
1044
- const extractedClaims = await this.extractKeyClaims(sources);
1045
-
1122
+ const extractedClaims = await this.extractKeyClaims(sources, topic);
1123
+
1046
1124
  // Group related claims
1047
- const claimGroups = this.groupRelatedClaims(extractedClaims);
1125
+ const claimGroups = await this.groupRelatedClaims(extractedClaims, topic);
1048
1126
 
1049
1127
  // Detect conflicts between claims
1050
1128
  if (this.enableConflictDetection) {
1051
- synthesis.conflicts = this.detectInformationConflicts(claimGroups);
1129
+ synthesis.conflicts = await this.detectInformationConflicts(claimGroups, topic);
1052
1130
  this.metrics.conflictsDetected = synthesis.conflicts.length;
1053
1131
  }
1054
1132
 
@@ -1072,12 +1150,24 @@ export class ResearchOrchestrator extends EventEmitter {
1072
1150
  try {
1073
1151
  this.logger.info('Generating LLM-powered research synthesis');
1074
1152
 
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
- }));
1153
+ // Prepare findings for LLM analysis. A vendor's promotional claim
1154
+ // about its own product is not a research conclusion — the synthesis
1155
+ // otherwise recommends whichever vendor's page ranked best. A finding
1156
+ // the LLM scored as only loosely about the topic is withheld for the
1157
+ // same reason: it stays in the reported evidence, but it is not
1158
+ // material to conclude from. Only withheld while something else
1159
+ // remains to synthesize.
1160
+ const conclusive = synthesis.keyFindings.filter(finding =>
1161
+ !finding.promotional &&
1162
+ (typeof finding.topicRelevance !== 'number' ||
1163
+ finding.topicRelevance >= MIN_SYNTHESIS_TOPIC_RELEVANCE)
1164
+ );
1165
+ const findingsForLLM = (conclusive.length > 0 ? conclusive : synthesis.keyFindings)
1166
+ .map(finding => ({
1167
+ finding: finding.finding,
1168
+ credibility: finding.credibility,
1169
+ sources: finding.sources.length
1170
+ }));
1081
1171
 
1082
1172
  const llmSynthesis = await this.llmManager.synthesizeFindings(
1083
1173
  findingsForLLM,
@@ -1124,7 +1214,7 @@ export class ResearchOrchestrator extends EventEmitter {
1124
1214
  /**
1125
1215
  * Extract key claims from source content
1126
1216
  */
1127
- async extractKeyClaims(sources) {
1217
+ async extractKeyClaims(sources, topic) {
1128
1218
  const claims = [];
1129
1219
 
1130
1220
  for (const source of sources) {
@@ -1146,13 +1236,22 @@ export class ResearchOrchestrator extends EventEmitter {
1146
1236
  // Handle both keypoints (tool output) and keyPoints (legacy) property names
1147
1237
  const keyPoints = summary.keypoints || summary.keyPoints || [];
1148
1238
  if (keyPoints.length > 0) {
1239
+ const relevance = this.sourceRelevance(source);
1149
1240
  keyPoints.forEach((point, index) => {
1241
+ const credibility = source.overallCredibility || 0.65;
1242
+ // A recommendation is not evidence. Two routes to the same flag: a
1243
+ // page promoting itself, and — whoever published it — a claim whose
1244
+ // subject is a named offering credited with doing the work.
1245
+ const promotional = isVendorSelfPromotion(point, source.link) ||
1246
+ isProductRecommendation(point);
1150
1247
  claims.push({
1151
1248
  id: `${source.link}_claim_${index}`,
1152
1249
  claim: point,
1153
1250
  source: source.link,
1154
1251
  sourceTitle: source.title,
1155
- credibility: source.overallCredibility || 0.65,
1252
+ credibility: promotional ? credibility * VENDOR_PROMO_CREDIBILITY_FACTOR : credibility,
1253
+ relevance,
1254
+ promotional,
1156
1255
  context: summary.supporting?.[index] || '',
1157
1256
  extractedAt: new Date().toISOString()
1158
1257
  });
@@ -1166,93 +1265,252 @@ export class ResearchOrchestrator extends EventEmitter {
1166
1265
  }
1167
1266
  }
1168
1267
 
1169
- return claims;
1268
+ await this.scoreClaimTopicRelevance(claims, topic);
1269
+
1270
+ return this.admitClaims(claims);
1170
1271
  }
1171
1272
 
1172
1273
  /**
1173
- * Group related claims for analysis
1274
+ * Record how much each individual claim is about the research topic.
1275
+ *
1276
+ * One batched LLM call for the whole run. The score lands on a separate
1277
+ * `topicRelevance` field, and only when the model actually scored it: an
1278
+ * entry may be null for a claim it skipped, which leaves that claim
1279
+ * unscored and therefore unfiltered. `relevance` is the source page's and
1280
+ * means something different. Claims are left unscored — and therefore
1281
+ * unfiltered — whenever the LLM cannot answer, so a failure here reproduces
1282
+ * the behaviour of not having asked.
1174
1283
  */
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);
1284
+ async scoreClaimTopicRelevance(claims, topic) {
1285
+ if (!this.enableLLMFeatures || !topic || claims.length === 0) return;
1286
+
1287
+ try {
1288
+ const scores = await this.llmManager.scoreClaimRelevance(
1289
+ claims.map(claim => claim.claim),
1290
+ topic
1291
+ );
1292
+ this.metrics.llmAnalysisCalls++;
1293
+
1294
+ if (!Array.isArray(scores) || scores.length !== claims.length) return;
1295
+
1296
+ claims.forEach((claim, index) => {
1297
+ // null marks a claim the model did not score, and stays unscored —
1298
+ // never 0, which would drop it. Number.isFinite rather than a typeof
1299
+ // check because typeof NaN is 'number', and a recorded NaN fails every
1300
+ // >= comparison below, silently rejecting a good claim.
1301
+ if (Number.isFinite(scores[index])) claim.topicRelevance = scores[index];
1302
+ });
1303
+ } catch (error) {
1304
+ this.logger.warn('Claim relevance scoring failed', { error: error.message });
1193
1305
  }
1306
+ }
1307
+
1308
+ /**
1309
+ * Topical relevance recorded for a source during deep exploration, or
1310
+ * undefined when none was computed (extraction failed before analysis).
1311
+ */
1312
+ sourceRelevance(source) {
1313
+ if (typeof source.relevanceScore === 'number') return source.relevanceScore;
1314
+ return this.researchState?.relevanceScores?.get(source.link);
1315
+ }
1194
1316
 
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;
1317
+ /**
1318
+ * Keep only claims that can be research findings: prose about the topic, not
1319
+ * document front matter (author/affiliation blocks, DOI stubs, "Retrieved
1320
+ * from" lines), not from a source the relevance analysis scored below
1321
+ * MIN_CLAIM_RELEVANCE, and not a sentence the LLM scored as barely about the
1322
+ * topic. Each gate falls back to the previous claim set rather than returning
1323
+ * nothing — no claims means no findings at all.
1324
+ */
1325
+ admitClaims(claims) {
1326
+ if (claims.length === 0) return claims;
1327
+
1328
+ const substantive = claims.filter(claim => isAdmissibleClaim(claim.claim));
1329
+ const relevant = substantive.filter(
1330
+ claim => typeof claim.relevance !== 'number' || claim.relevance >= MIN_CLAIM_RELEVANCE
1331
+ );
1332
+ const onTopic = relevant.filter(
1333
+ claim => typeof claim.topicRelevance !== 'number' ||
1334
+ claim.topicRelevance >= MIN_CLAIM_TOPIC_RELEVANCE
1335
+ );
1336
+
1337
+ const admitted = onTopic.length > 0
1338
+ ? onTopic
1339
+ : (relevant.length > 0 ? relevant : (substantive.length > 0 ? substantive : claims));
1340
+
1341
+ this.logger.debug('Claim admission', {
1342
+ candidates: claims.length,
1343
+ substantive: substantive.length,
1344
+ relevant: relevant.length,
1345
+ onTopic: onTopic.length,
1346
+ admitted: admitted.length
1199
1347
  });
1200
1348
 
1201
- return Array.from(groups.values());
1349
+ return admitted;
1202
1350
  }
1203
1351
 
1204
1352
  /**
1205
- * Detect conflicts between information claims
1353
+ * Group related claims for analysis.
1354
+ *
1355
+ * Semantically when the LLM can partition them, otherwise by the claim's own
1356
+ * first-three-sorted keywords. The keyword key splits paraphrases — measured
1357
+ * on 27 claims from a live run it produced 27 groups, none with more than one
1358
+ * claim, which makes consensus (needs sourceCount >= 2) and conflict
1359
+ * detection (needs two claims in a group) structurally unreachable.
1206
1360
  */
1207
- detectInformationConflicts(claimGroups) {
1208
- const conflicts = [];
1209
-
1361
+ async groupRelatedClaims(claims, topic) {
1362
+ const semantic = await this.semanticClaimGroups(claims, topic);
1363
+ if (semantic) return semantic;
1364
+
1365
+ const byKeywordKey = new Map();
1366
+
1367
+ for (const claim of claims) {
1368
+ const groupKey = this.extractKeywords(claim.claim).slice(0, 3).sort().join('_');
1369
+ if (!byKeywordKey.has(groupKey)) byKeywordKey.set(groupKey, []);
1370
+ byKeywordKey.get(groupKey).push(claim);
1371
+ }
1372
+
1373
+ return Array.from(byKeywordKey, ([key, grouped]) => this.buildClaimGroup(key, grouped));
1374
+ }
1375
+
1376
+ /**
1377
+ * Groups from the LLM's semantic partition, or null to use the keyword key.
1378
+ *
1379
+ * Null on every path the contract calls a failure: LLM features off, no
1380
+ * topic, an empty partition. A partition that does not cover the claims
1381
+ * exactly once would silently drop evidence from the run, so that falls back
1382
+ * too rather than being trusted.
1383
+ */
1384
+ async semanticClaimGroups(claims, topic) {
1385
+ if (!this.enableLLMFeatures || !topic || claims.length === 0) return null;
1386
+
1387
+ let partition;
1388
+ try {
1389
+ partition = await this.llmManager.groupClaimsBySimilarity(
1390
+ claims.map(claim => claim.claim),
1391
+ topic
1392
+ );
1393
+ this.metrics.llmAnalysisCalls++;
1394
+ } catch (error) {
1395
+ this.logger.warn('Semantic claim grouping failed', { error: error.message });
1396
+ return null;
1397
+ }
1398
+
1399
+ if (!Array.isArray(partition)) return null;
1400
+ if (!partition.every(group => Array.isArray(group) && group.length > 0)) return null;
1401
+
1402
+ const indices = partition.flat();
1403
+ const isPartition = indices.length === claims.length &&
1404
+ new Set(indices).size === claims.length &&
1405
+ indices.every(i => Number.isInteger(i) && i >= 0 && i < claims.length);
1406
+ if (!isPartition) return null;
1407
+
1408
+ return partition.map((group, index) =>
1409
+ this.buildClaimGroup(`semantic_${index}`, group.map(i => claims[i]))
1410
+ );
1411
+ }
1412
+
1413
+ /**
1414
+ * A claim group and its statistics. Both grouping paths build groups here so
1415
+ * sourceCount and avgCredibility cannot diverge between them.
1416
+ */
1417
+ buildClaimGroup(id, claims) {
1418
+ return {
1419
+ id,
1420
+ keywords: this.extractKeywords(claims[0].claim),
1421
+ claims,
1422
+ sourceCount: new Set(claims.map(c => c.source)).size,
1423
+ avgCredibility: claims.reduce((sum, c) => sum + c.credibility, 0) / claims.length
1424
+ };
1425
+ }
1426
+
1427
+ /**
1428
+ * Detect conflicts between information claims.
1429
+ *
1430
+ * Only the LLM decides. Candidate pairs are drawn from within a semantic
1431
+ * group — claims already judged to be about the same thing — and the whole
1432
+ * batch goes to the model in one call; a pair becomes a conflict only if the
1433
+ * model names it.
1434
+ *
1435
+ * Fails CLOSED, in every sense: no LLM, an error, an unusable answer, or
1436
+ * nothing found all report zero conflicts. Zero is an honest answer. Two
1437
+ * lexical detectors have now been tried and both produced pure noise — the
1438
+ * second reported 42 conflicts on a live run, none of them real, because
1439
+ * extractive claims are long multi-sentence blobs and nearly every pair
1440
+ * contains both a negation and an affirmation somewhere. There is no
1441
+ * sentence-shape repair for that, so there is no fallback path here.
1442
+ */
1443
+ async detectInformationConflicts(claimGroups, topic) {
1444
+ if (!ENABLE_LLM_CONFLICT_DETECTION) return [];
1445
+ if (!this.enableLLMFeatures) return [];
1446
+
1447
+ const pairs = [];
1210
1448
  for (const group of claimGroups) {
1211
1449
  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
- }
1450
+
1451
+ // Pairs grow quadratically, so compare only a group's most credible
1452
+ // claims and bound the batch overall — this runs inside the tool's
1453
+ // wall-clock limit.
1454
+ const claims = [...group.claims]
1455
+ .sort((a, b) => (b.credibility || 0) - (a.credibility || 0))
1456
+ .slice(0, MAX_CONFLICT_CLAIMS_PER_GROUP);
1457
+
1458
+ for (let i = 0; i < claims.length; i++) {
1459
+ for (let j = i + 1; j < claims.length; j++) {
1460
+ pairs.push({ a: claims[i], b: claims[j] });
1243
1461
  }
1244
1462
  }
1245
1463
  }
1246
1464
 
1247
- return conflicts;
1465
+ const candidates = pairs.slice(0, MAX_CONFLICT_PAIRS);
1466
+ if (candidates.length === 0) return [];
1467
+
1468
+ let contradicting;
1469
+ try {
1470
+ contradicting = await this.llmManager.findContradictions(
1471
+ candidates.map(({ a, b }) => ({ a: a.claim, b: b.claim })),
1472
+ topic
1473
+ );
1474
+ this.metrics.llmAnalysisCalls++;
1475
+ } catch (error) {
1476
+ this.logger.warn('Contradiction detection failed', { error: error.message });
1477
+ return [];
1478
+ }
1479
+
1480
+ if (!Array.isArray(contradicting)) return [];
1481
+
1482
+ return contradicting
1483
+ .filter(index => Number.isInteger(index) && index >= 0 && index < candidates.length)
1484
+ .map((pairIndex, position) => {
1485
+ const { a, b } = candidates[pairIndex];
1486
+ return {
1487
+ id: `conflict_${position}`,
1488
+ type: 'contradiction',
1489
+ claim1: a,
1490
+ claim2: b,
1491
+ severity: this.calculateConflictSeverity(a, b),
1492
+ detectedAt: new Date().toISOString()
1493
+ };
1494
+ });
1248
1495
  }
1249
1496
 
1250
1497
  /**
1251
- * Identify areas of consensus
1498
+ * Identify areas of consensus.
1499
+ *
1500
+ * Corroboration is the load-bearing requirement: two independent sources
1501
+ * saying the same thing. The credibility floor is the tool's own
1502
+ * `credibilityThreshold` (default 0.3, caller-settable, and already what
1503
+ * generateKeyFindings and compileSupportingEvidence use) rather than a
1504
+ * separate hardcoded 0.6, which gated consensus out entirely on real
1505
+ * sources. Measured on the live 2026-08-28 run: source credibility spanned
1506
+ * 0.496-0.630 (n=7, avg 0.567) and only one of four findings cleared 0.6,
1507
+ * with VENDOR_PROMO_CREDIBILITY_FACTOR pulling promotional groups lower
1508
+ * still. The floor now only excludes what the caller already considers too
1509
+ * weak to be a finding at all.
1252
1510
  */
1253
1511
  identifyConsensus(claimGroups) {
1254
1512
  return claimGroups
1255
- .filter(group => group.sourceCount >= 2 && group.avgCredibility >= 0.6)
1513
+ .filter(group => group.sourceCount >= 2 && group.avgCredibility >= this.credibilityThreshold)
1256
1514
  .map(group => ({
1257
1515
  topic: this.claimGroupLabel(group),
1258
1516
  supportingClaims: group.claims.length,
@@ -1531,16 +1789,79 @@ export class ResearchOrchestrator extends EventEmitter {
1531
1789
  }
1532
1790
 
1533
1791
  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
- }));
1792
+ const limit = 10;
1793
+ const eligible = claimGroups.filter(group => group.avgCredibility >= this.credibilityThreshold);
1794
+
1795
+ // With a single reachable source there is nothing to diversify, so the
1796
+ // per-source cap only applies once findings could come from more than one.
1797
+ const distinctSources = new Set(eligible.flatMap(g => g.claims.map(c => c.source)));
1798
+ const perSourceCap = distinctSources.size > 1
1799
+ ? Math.max(1, Math.ceil(limit * MAX_FINDING_SHARE_PER_SOURCE))
1800
+ : limit;
1801
+
1802
+ const ranked = eligible
1803
+ // groupRelatedClaims never sets consensusStrength, so the sort below used
1804
+ // to compare undefined with undefined and order nothing.
1805
+ .map(group => ({ group, strength: group.consensusStrength ?? this.calculateConsensusStrength(group) }))
1806
+ .sort((a, b) => {
1807
+ // A corroborated group (more than one supporting claim) outranks a
1808
+ // lone claim regardless of strength.
1809
+ const corroboration = Number(b.group.claims.length > 1) - Number(a.group.claims.length > 1);
1810
+ return corroboration !== 0 ? corroboration : b.strength - a.strength;
1811
+ });
1812
+
1813
+ // Queue the ranked groups per source that supplies the surfaced claim, then
1814
+ // take one from each queue per round. Diversity has to hold at the TOP of
1815
+ // the list, not only in aggregate: deepResearch.js re-slices findings to 5
1816
+ // for outputFormat 'summary', and an aggregate cap of 4-in-10 still allows
1817
+ // 4 of the first 5 to come from one URL. Interleaving guarantees a
1818
+ // positional property instead — a source contributes a second finding only
1819
+ // after every other source with findings left has contributed one — while
1820
+ // the per-source cap bounds a source that outlasts all the others.
1821
+ const queues = new Map(); // insertion order: strongest source first
1822
+ for (const { group } of ranked) {
1823
+ const claim = this.mostCredibleClaim(group);
1824
+ if (!queues.has(claim.source)) queues.set(claim.source, []);
1825
+ queues.get(claim.source).push({ group, claim });
1826
+ }
1827
+
1828
+ // Interleave the strongest SUMMARY_SLICE sources first and only widen to
1829
+ // the rest once those queues run dry. Interleaving every source instead
1830
+ // measurably degraded the research (live 2026-08-28): with ten thin sources
1831
+ // it spent all ten slots on one line each — including a bot-check
1832
+ // interstitial — and pushed the sources that actually covered the topic
1833
+ // down to a single claim apiece.
1834
+ const ordered = Array.from(queues.values());
1835
+ const findings = [];
1836
+
1837
+ for (const pool of [ordered.slice(0, SUMMARY_SLICE), ordered.slice(SUMMARY_SLICE)]) {
1838
+ for (let round = 0; findings.length < limit && round < perSourceCap; round++) {
1839
+ let advanced = false;
1840
+
1841
+ for (const queue of pool) {
1842
+ if (round >= queue.length) continue;
1843
+ advanced = true;
1844
+
1845
+ const { group, claim } = queue[round];
1846
+ findings.push({
1847
+ finding: claim.claim,
1848
+ supportingClaims: group.claims.length,
1849
+ credibility: group.avgCredibility,
1850
+ sources: group.claims.map(c => c.source),
1851
+ ...(claim.promotional ? { promotional: true } : {}),
1852
+ ...(typeof claim.topicRelevance === 'number'
1853
+ ? { topicRelevance: claim.topicRelevance }
1854
+ : {})
1855
+ });
1856
+
1857
+ if (findings.length >= limit) break;
1858
+ }
1859
+
1860
+ if (!advanced) break;
1861
+ }
1862
+ }
1863
+
1864
+ return findings;
1544
1865
  }
1545
1866
 
1546
1867
  compileSupportingEvidence(sources) {