crawlforge-mcp-server 5.2.8 → 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.
- package/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -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 +407 -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 +517 -13
- 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 +1 -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/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,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: {
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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:
|
|
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
|
-
|
|
1268
|
+
await this.scoreClaimTopicRelevance(claims, topic);
|
|
1269
|
+
|
|
1270
|
+
return this.admitClaims(claims);
|
|
1170
1271
|
}
|
|
1171
1272
|
|
|
1172
1273
|
/**
|
|
1173
|
-
*
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
|
1349
|
+
return admitted;
|
|
1202
1350
|
}
|
|
1203
1351
|
|
|
1204
1352
|
/**
|
|
1205
|
-
*
|
|
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
|
-
|
|
1208
|
-
const
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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) {
|