@devilsdev/rag-pipeline-utils 2.3.1 → 2.4.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 (160) hide show
  1. package/README.md +94 -480
  2. package/contracts/loader-contract.json +11 -6
  3. package/contracts/reranker-contract.json +18 -26
  4. package/dist/index.cjs +21190 -47
  5. package/dist/index.d.ts +298 -68
  6. package/dist/index.mjs +9 -76
  7. package/package.json +37 -11
  8. package/src/agentic/agentic-pipeline.js +235 -0
  9. package/src/agentic/iterative-retriever.js +213 -0
  10. package/src/agentic/query-planner.js +208 -0
  11. package/src/agentic/self-critique.js +102 -0
  12. package/src/ai/adaptive-retrieval.js +0 -3
  13. package/src/ai/federated-learning.js +0 -3
  14. package/src/ai/federation.js +1 -6
  15. package/src/ai/index.js +9 -20
  16. package/src/ai/model-training.js +0 -3
  17. package/src/ai/multimodal-processing.js +0 -3
  18. package/src/ai/multimodal.js +1 -6
  19. package/src/ai/orchestrator.js +1 -6
  20. package/src/ai/retrieval-engine.js +1 -6
  21. package/src/chunking/chunking-engine.js +142 -0
  22. package/src/chunking/strategies/fixed-size.js +54 -0
  23. package/src/chunking/strategies/recursive.js +127 -0
  24. package/src/chunking/strategies/semantic.js +128 -0
  25. package/src/chunking/strategies/sentence.js +32 -0
  26. package/src/chunking/strategies/structure-aware.js +138 -0
  27. package/src/citation/citation-tracker.js +161 -0
  28. package/src/citation/hallucination-detector.js +74 -0
  29. package/src/citation/source-mapper.js +95 -0
  30. package/src/cli/commands/ai-ml/adaptive-commands.js +178 -0
  31. package/src/cli/commands/ai-ml/federated-commands.js +207 -0
  32. package/src/cli/commands/ai-ml/index.js +17 -0
  33. package/src/cli/commands/ai-ml/multimodal-commands.js +187 -0
  34. package/src/cli/commands/ai-ml/shared.js +51 -0
  35. package/src/cli/commands/ai-ml/training-commands.js +228 -0
  36. package/src/cli/commands/ai-ml.js +176 -757
  37. package/src/cli/commands/plugin-hub/certification-commands.js +221 -0
  38. package/src/cli/commands/plugin-hub/event-handlers.js +64 -0
  39. package/src/cli/commands/plugin-hub/hub-commands.js +290 -0
  40. package/src/cli/commands/plugin-hub/review-commands.js +78 -0
  41. package/src/cli/commands/plugin-hub.js +106 -782
  42. package/src/cli/diagnostics/config-checker.js +81 -0
  43. package/src/cli/diagnostics/dependency-checker.js +69 -0
  44. package/src/cli/diagnostics/environment-checker.js +45 -0
  45. package/src/cli/diagnostics/performance-checker.js +51 -0
  46. package/src/cli/diagnostics/plugin-checker.js +106 -0
  47. package/src/cli/diagnostics/security-checker.js +61 -0
  48. package/src/cli/doctor-command.js +70 -369
  49. package/src/cli/enhanced-cli-commands.js +162 -706
  50. package/src/cli/handlers/config-handler.js +126 -0
  51. package/src/cli/handlers/diagnostics-handler.js +113 -0
  52. package/src/cli/handlers/ingest-handler.js +42 -0
  53. package/src/cli/handlers/init-handler.js +51 -0
  54. package/src/cli/handlers/query-handler.js +45 -0
  55. package/src/cli/handlers/run-handler.js +181 -0
  56. package/src/cli/interactive-wizard.js +99 -723
  57. package/src/cli/shared/cli-utils.js +89 -0
  58. package/src/cli/wizard/configuration.js +148 -0
  59. package/src/cli/wizard/observability.js +79 -0
  60. package/src/cli/wizard/performance.js +76 -0
  61. package/src/cli/wizard/plugin-selection.js +297 -0
  62. package/src/cli/wizard/preview-save.js +172 -0
  63. package/src/cli/wizard/project-setup.js +66 -0
  64. package/src/config/memory-optimization.js +3 -3
  65. package/src/connectors/anthropic-connector.js +179 -0
  66. package/src/connectors/base-connector.js +94 -0
  67. package/src/connectors/cohere-connector.js +143 -0
  68. package/src/connectors/local-embedder.js +171 -0
  69. package/src/connectors/memory-retriever.js +150 -0
  70. package/src/connectors/ollama-connector.js +205 -0
  71. package/src/connectors/openai-connector.js +173 -0
  72. package/src/core/create-pipeline.js +21 -1
  73. package/src/core/observability/tracing.js +2 -1
  74. package/src/core/pipeline-factory.js +0 -1
  75. package/src/core/plugin-registry.js +14 -14
  76. package/src/cost/cost-calculator.js +146 -0
  77. package/src/cost/cost-tracker.js +260 -0
  78. package/src/cost/token-budget.js +245 -0
  79. package/src/dag/core/execution-scheduler.js +2 -1
  80. package/src/dag/core/topology-validation.js +0 -3
  81. package/src/dag/dag-engine.js +3 -5
  82. package/src/dag/dag-node.js +0 -1
  83. package/src/dag/enhanced-dag-engine.js +171 -99
  84. package/src/dashboard/dashboard-generator.js +642 -0
  85. package/src/dashboard/metrics-aggregator.js +409 -0
  86. package/src/debug/execution-tracer.js +210 -0
  87. package/src/debug/trace-inspector.js +281 -0
  88. package/src/dev/dev-server.js +5 -5
  89. package/src/dev/hot-reload.js +3 -2
  90. package/src/dx/integration-templates.js +0 -3
  91. package/src/ecosystem/plugin-analytics-dashboard.js +2 -2
  92. package/src/ecosystem/plugin-hub.js +2 -2
  93. package/src/enterprise/audit-logging.js +2 -2
  94. package/src/enterprise/sso-integration.js +52 -32
  95. package/src/evaluate/context-metrics.js +127 -0
  96. package/src/evaluate/evaluator.js +4 -2
  97. package/src/evaluate/faithfulness.js +117 -0
  98. package/src/evaluate/groundedness.js +60 -0
  99. package/src/evaluate/pipeline-evaluator.js +156 -0
  100. package/src/evaluate/relevance.js +129 -0
  101. package/src/evaluate/scoring.js +27 -0
  102. package/src/graph/entity-extractor.js +183 -0
  103. package/src/graph/graph-index.js +113 -0
  104. package/src/graph/graph-retriever.js +244 -0
  105. package/src/graph/knowledge-graph.js +256 -0
  106. package/src/guardrails/guardrails-pipeline.js +136 -0
  107. package/src/guardrails/post-generation-guard.js +170 -0
  108. package/src/guardrails/pre-retrieval-guard.js +161 -0
  109. package/src/guardrails/retrieval-guard.js +126 -0
  110. package/src/index.js +255 -9
  111. package/src/loader/html-loader.js +4 -15
  112. package/src/loader/markdown-loader.js +4 -15
  113. package/src/mcp/mcp-server.js +199 -0
  114. package/src/mcp/mcp-tool-builder.js +121 -0
  115. package/src/observability/audit-logger.js +0 -2
  116. package/src/observability/event-logger.js +2 -1
  117. package/src/observability/security-monitor.js +0 -2
  118. package/src/observability/telemetry.js +0 -2
  119. package/src/reranker/cascade-reranker.js +79 -0
  120. package/src/reranker/embedding-reranker.js +124 -0
  121. package/src/reranker/llm-reranker.js +6 -3
  122. package/src/reranker/scoring-reranker.js +123 -0
  123. package/src/retrieval/bm25-search.js +157 -0
  124. package/src/retrieval/hybrid-retriever.js +111 -0
  125. package/src/retrieval/rank-fusion.js +62 -0
  126. package/src/security/jwt-config.js +4 -3
  127. package/src/security/jwt-validator.js +2 -1
  128. package/src/security/plugin-sandbox.js +0 -3
  129. package/src/security/plugin-signature-verifier.js +4 -6
  130. package/src/security/validator.js +0 -2
  131. package/src/streaming/embedding-stream.js +197 -0
  132. package/src/streaming/sse-adapter.js +168 -0
  133. package/src/streaming/stream-router.js +210 -0
  134. package/src/streaming/streaming-embedder.js +231 -0
  135. package/src/streaming/websocket-adapter.js +171 -0
  136. package/src/templates/scaffold.js +147 -0
  137. package/src/templates/template-registry.js +272 -0
  138. package/src/utils/contract-validator.js +2 -1
  139. package/src/utils/input-sanitizer.js +80 -99
  140. package/src/utils/performance-monitor.js +0 -3
  141. package/src/utils/plugin-scaffolder.js +9 -16
  142. package/src/utils/rate-limiter.js +0 -1
  143. package/src/utils/retry-policy.js +0 -3
  144. package/src/utils/safe-expression-evaluator.js +0 -3
  145. package/src/utils/secure-logger-config.js +2 -4
  146. package/src/utils/secure-logging.js +0 -3
  147. package/src/utils/structured-logger.js +0 -3
  148. package/src/ai/index-fixed.js +0 -509
  149. package/src/ai/module-template.js +0 -37
  150. package/src/ci/workflow-analyzer.js +0 -71
  151. package/src/cli/plugin-marketplace-commands.js +0 -869
  152. package/src/dev/README.md +0 -126
  153. package/src/dev/examples/hot-reload-usage.js +0 -355
  154. package/src/mocks/openai-embedder.js +0 -119
  155. package/src/mocks/openai-llm.js +0 -77
  156. package/src/mocks/pdf-loader.js +0 -21
  157. package/src/mocks/pinecone-retriever.js +0 -106
  158. package/src/scripts/script-utilities.js +0 -36
  159. package/src/utils/ci/diagnostic-reporter.js +0 -57
  160. package/src/utils/examples/error-formatter-usage.js +0 -351
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ splitSentences,
5
+ tokenize,
6
+ computeJaccardSimilarity,
7
+ } = require('./scoring');
8
+ const { mapSentenceToSources } = require('../citation/source-mapper');
9
+
10
+ /**
11
+ * Compute faithfulness score for an answer against retrieved documents.
12
+ * Measures how well each sentence in the answer is supported by the retrieved context.
13
+ *
14
+ * @param {string} answer - The generated answer text
15
+ * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents with content
16
+ * @param {object} [options={}] - Configuration options
17
+ * @param {number} [options.threshold=0.3] - Minimum similarity score to consider a sentence faithful
18
+ * @returns {{ score: number, faithfulSentences: number, totalSentences: number, details: Array<{ sentence: string, score: number, isFaithful: boolean, bestSource: number }> }}
19
+ */
20
+ function computeFaithfulness(answer, retrievedDocs, options = {}) {
21
+ const { threshold = 0.3 } = options;
22
+
23
+ if (!answer || typeof answer !== 'string' || answer.trim().length === 0) {
24
+ return { score: 0, faithfulSentences: 0, totalSentences: 0, details: [] };
25
+ }
26
+
27
+ if (!Array.isArray(retrievedDocs) || retrievedDocs.length === 0) {
28
+ const sentences = splitSentences(answer);
29
+ const details = sentences.map((sentence) => ({
30
+ sentence,
31
+ score: 0,
32
+ isFaithful: false,
33
+ bestSource: -1,
34
+ }));
35
+ return {
36
+ score: 0,
37
+ faithfulSentences: 0,
38
+ totalSentences: sentences.length,
39
+ details,
40
+ };
41
+ }
42
+
43
+ const sentences = splitSentences(answer);
44
+
45
+ if (sentences.length === 0) {
46
+ return { score: 0, faithfulSentences: 0, totalSentences: 0, details: [] };
47
+ }
48
+
49
+ const details = sentences.map((sentence) => {
50
+ const sentenceTokens = tokenize(sentence);
51
+ let bestScore = 0;
52
+ let bestSource = -1;
53
+
54
+ for (let i = 0; i < retrievedDocs.length; i++) {
55
+ const doc = retrievedDocs[i];
56
+ const docContent = doc && (doc.content || doc.text || '');
57
+
58
+ if (!docContent) {
59
+ continue;
60
+ }
61
+
62
+ const docTokens = tokenize(docContent);
63
+ const similarity = computeJaccardSimilarity(sentenceTokens, docTokens);
64
+
65
+ if (similarity > bestScore) {
66
+ bestScore = similarity;
67
+ bestSource = i;
68
+ }
69
+ }
70
+
71
+ return {
72
+ sentence,
73
+ score: bestScore,
74
+ isFaithful: bestScore >= threshold,
75
+ bestSource,
76
+ };
77
+ });
78
+
79
+ const faithfulSentences = details.filter((d) => d.isFaithful).length;
80
+ const totalSentences = details.length;
81
+ const score = totalSentences > 0 ? faithfulSentences / totalSentences : 0;
82
+
83
+ return { score, faithfulSentences, totalSentences, details };
84
+ }
85
+
86
+ /**
87
+ * Derive faithfulness score from an existing citation result.
88
+ * Shortcut that reuses citation analysis data rather than recomputing.
89
+ *
90
+ * @param {object} citationResult - Result from citation analysis
91
+ * @param {number} citationResult.groundednessScore - Pre-computed groundedness score
92
+ * @param {Array} [citationResult.citations] - Array of citation details
93
+ * @returns {{ score: number, details: Array }}
94
+ */
95
+ function computeFaithfulnessFromCitations(citationResult) {
96
+ if (!citationResult || typeof citationResult !== 'object') {
97
+ return { score: 0, details: [] };
98
+ }
99
+
100
+ const score =
101
+ typeof citationResult.groundednessScore === 'number'
102
+ ? citationResult.groundednessScore
103
+ : 0;
104
+
105
+ const details = Array.isArray(citationResult.citations)
106
+ ? citationResult.citations.map((c) => ({
107
+ sentence: c.sentence || c.text || '',
108
+ score: c.score || c.similarity || 0,
109
+ isFaithful: (c.score || c.similarity || 0) > 0,
110
+ bestSource: c.sourceIndex != null ? c.sourceIndex : -1,
111
+ }))
112
+ : [];
113
+
114
+ return { score, details };
115
+ }
116
+
117
+ module.exports = { computeFaithfulness, computeFaithfulnessFromCitations };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const { computeFaithfulness } = require('./faithfulness');
4
+ const {
5
+ computeContextPrecision,
6
+ computeContextRecall,
7
+ } = require('./context-metrics');
8
+
9
+ /**
10
+ * Default weights for the groundedness composite score.
11
+ * @type {{ faithfulness: number, contextPrecision: number, contextRecall: number }}
12
+ */
13
+ const DEFAULT_WEIGHTS = {
14
+ faithfulness: 0.4,
15
+ contextPrecision: 0.3,
16
+ contextRecall: 0.3,
17
+ };
18
+
19
+ /**
20
+ * Compute a composite groundedness score combining faithfulness, context precision, and context recall.
21
+ * Uses a weighted average of the three component metrics.
22
+ *
23
+ * @param {string} query - The user query
24
+ * @param {string} answer - The generated answer text
25
+ * @param {Array<{content: string}>} retrievedDocs - Array of retrieved documents
26
+ * @param {object} [options={}] - Configuration options
27
+ * @param {{ faithfulness: number, contextPrecision: number, contextRecall: number }} [options.weights] - Custom weights for each component
28
+ * @param {number} [options.threshold=0.3] - Similarity threshold passed to component metrics
29
+ * @returns {{ score: number, components: { faithfulness: object, contextPrecision: object, contextRecall: object }, weights: { faithfulness: number, contextPrecision: number, contextRecall: number } }}
30
+ */
31
+ function computeGroundedness(query, answer, retrievedDocs, options = {}) {
32
+ const { weights = DEFAULT_WEIGHTS, threshold = 0.3 } = options;
33
+
34
+ const faithfulness = computeFaithfulness(answer, retrievedDocs, {
35
+ threshold,
36
+ });
37
+ const contextPrecision = computeContextPrecision(query, retrievedDocs, {
38
+ threshold: threshold * 0.67,
39
+ });
40
+ const contextRecall = computeContextRecall(answer, retrievedDocs, {
41
+ threshold,
42
+ });
43
+
44
+ const score =
45
+ weights.faithfulness * faithfulness.score +
46
+ weights.contextPrecision * contextPrecision.score +
47
+ weights.contextRecall * contextRecall.score;
48
+
49
+ return {
50
+ score,
51
+ components: {
52
+ faithfulness,
53
+ contextPrecision,
54
+ contextRecall,
55
+ },
56
+ weights,
57
+ };
58
+ }
59
+
60
+ module.exports = { computeGroundedness, DEFAULT_WEIGHTS };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const {
5
+ computeFaithfulness,
6
+ computeFaithfulnessFromCitations,
7
+ } = require('./faithfulness');
8
+ const { computeAnswerRelevance } = require('./relevance');
9
+ const {
10
+ computeContextPrecision,
11
+ computeContextRecall,
12
+ } = require('./context-metrics');
13
+ const { computeGroundedness } = require('./groundedness');
14
+ const { scoreAnswer } = require('./scoring');
15
+
16
+ /**
17
+ * Default configuration for PipelineEvaluator.
18
+ * @type {{ metrics: string[], threshold: number, includeBLEU: boolean, includeROUGE: boolean }}
19
+ */
20
+ const DEFAULT_CONFIG = {
21
+ metrics: [
22
+ 'faithfulness',
23
+ 'relevance',
24
+ 'contextPrecision',
25
+ 'contextRecall',
26
+ 'groundedness',
27
+ ],
28
+ threshold: 0.3,
29
+ includeBLEU: false,
30
+ includeROUGE: false,
31
+ };
32
+
33
+ /**
34
+ * Evaluates RAG pipeline outputs using multiple quality metrics.
35
+ * Emits an 'evaluated' event after each evaluation with the full result.
36
+ *
37
+ * @extends EventEmitter
38
+ *
39
+ * @example
40
+ * const evaluator = new PipelineEvaluator({ metrics: ['faithfulness', 'relevance'] });
41
+ * evaluator.on('evaluated', (result) => console.log(result.scores));
42
+ * const result = evaluator.evaluate({ query: 'What is RAG?', answer: '...', results: [...] });
43
+ */
44
+ class PipelineEvaluator extends EventEmitter {
45
+ /**
46
+ * Create a new PipelineEvaluator.
47
+ * @param {object} [options={}] - Configuration options
48
+ * @param {string[]} [options.metrics] - List of metrics to compute
49
+ * @param {number} [options.threshold] - Similarity threshold for metrics
50
+ * @param {boolean} [options.includeBLEU] - Whether to include BLEU score when reference answer is provided
51
+ * @param {boolean} [options.includeROUGE] - Whether to include ROUGE score when reference answer is provided
52
+ */
53
+ constructor(options = {}) {
54
+ super();
55
+ this.config = { ...DEFAULT_CONFIG, ...options };
56
+ }
57
+
58
+ /**
59
+ * Evaluate a pipeline result across all configured metrics.
60
+ *
61
+ * @param {object} pipelineResult - The pipeline output to evaluate
62
+ * @param {string} pipelineResult.query - The original user query
63
+ * @param {string} pipelineResult.answer - The generated answer
64
+ * @param {Array<{content: string}>} pipelineResult.results - Retrieved documents
65
+ * @param {object} [options={}] - Additional evaluation options
66
+ * @param {object} [options.citationResult] - Pre-computed citation result for faithfulness shortcut
67
+ * @param {string} [options.referenceAnswer] - Reference answer for BLEU/ROUGE scoring
68
+ * @returns {{ scores: Object<string, number>, details: Object<string, object>, metadata: { metricsComputed: string[], threshold: number, timestamp: string } }}
69
+ */
70
+ evaluate(pipelineResult, options = {}) {
71
+ const { citationResult, referenceAnswer } = options;
72
+ const query = (pipelineResult && pipelineResult.query) || '';
73
+ const answer = (pipelineResult && pipelineResult.answer) || '';
74
+ const results = (pipelineResult && pipelineResult.results) || [];
75
+
76
+ const scores = {};
77
+ const details = {};
78
+ const metricsComputed = [];
79
+ const { metrics, threshold } = this.config;
80
+
81
+ if (metrics.includes('faithfulness')) {
82
+ let faithResult;
83
+ if (citationResult) {
84
+ faithResult = computeFaithfulnessFromCitations(citationResult);
85
+ } else {
86
+ faithResult = computeFaithfulness(answer, results, { threshold });
87
+ }
88
+ scores.faithfulness = faithResult.score;
89
+ details.faithfulness = faithResult;
90
+ metricsComputed.push('faithfulness');
91
+ }
92
+
93
+ if (metrics.includes('relevance')) {
94
+ const relevanceResult = computeAnswerRelevance(query, answer);
95
+ scores.relevance = relevanceResult.score;
96
+ details.relevance = relevanceResult;
97
+ metricsComputed.push('relevance');
98
+ }
99
+
100
+ if (metrics.includes('contextPrecision')) {
101
+ const precisionResult = computeContextPrecision(query, results, {
102
+ threshold: threshold * 0.67,
103
+ });
104
+ scores.contextPrecision = precisionResult.score;
105
+ details.contextPrecision = precisionResult;
106
+ metricsComputed.push('contextPrecision');
107
+ }
108
+
109
+ if (metrics.includes('contextRecall')) {
110
+ const recallResult = computeContextRecall(answer, results, { threshold });
111
+ scores.contextRecall = recallResult.score;
112
+ details.contextRecall = recallResult;
113
+ metricsComputed.push('contextRecall');
114
+ }
115
+
116
+ if (metrics.includes('groundedness')) {
117
+ const groundednessResult = computeGroundedness(query, answer, results, {
118
+ threshold,
119
+ });
120
+ scores.groundedness = groundednessResult.score;
121
+ details.groundedness = groundednessResult;
122
+ metricsComputed.push('groundedness');
123
+ }
124
+
125
+ // Optional BLEU/ROUGE when reference answer is provided
126
+ if (referenceAnswer && typeof referenceAnswer === 'string') {
127
+ if (this.config.includeBLEU || this.config.includeROUGE) {
128
+ const refScores = scoreAnswer(answer, referenceAnswer);
129
+ if (this.config.includeBLEU) {
130
+ scores.bleu = refScores.bleu;
131
+ metricsComputed.push('bleu');
132
+ }
133
+ if (this.config.includeROUGE) {
134
+ scores.rouge = refScores.rouge;
135
+ metricsComputed.push('rouge');
136
+ }
137
+ }
138
+ }
139
+
140
+ const result = {
141
+ scores,
142
+ details,
143
+ metadata: {
144
+ metricsComputed,
145
+ threshold,
146
+ timestamp: new Date().toISOString(),
147
+ },
148
+ };
149
+
150
+ this.emit('evaluated', result);
151
+
152
+ return result;
153
+ }
154
+ }
155
+
156
+ module.exports = { PipelineEvaluator, DEFAULT_CONFIG };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const { tokenize, computeJaccardSimilarity } = require('./scoring');
4
+
5
+ /**
6
+ * Common English stopwords to skip when measuring query token coverage.
7
+ * Only tokens longer than 3 characters are considered, but these are
8
+ * additionally excluded to avoid inflating coverage scores.
9
+ * @type {Set<string>}
10
+ */
11
+ const STOPWORDS = new Set([
12
+ 'the',
13
+ 'that',
14
+ 'this',
15
+ 'with',
16
+ 'from',
17
+ 'have',
18
+ 'been',
19
+ 'will',
20
+ 'would',
21
+ 'could',
22
+ 'should',
23
+ 'does',
24
+ 'what',
25
+ 'when',
26
+ 'where',
27
+ 'which',
28
+ 'their',
29
+ 'there',
30
+ 'they',
31
+ 'them',
32
+ 'then',
33
+ 'than',
34
+ 'these',
35
+ 'those',
36
+ 'your',
37
+ 'about',
38
+ 'into',
39
+ 'each',
40
+ 'also',
41
+ 'more',
42
+ 'some',
43
+ 'such',
44
+ 'only',
45
+ 'over',
46
+ 'very',
47
+ 'just',
48
+ 'being',
49
+ 'were',
50
+ 'here',
51
+ ]);
52
+
53
+ /**
54
+ * Compute answer relevance score measuring how well the answer addresses the query.
55
+ * Combines Jaccard similarity with query token coverage for a balanced metric.
56
+ *
57
+ * @param {string} query - The user query
58
+ * @param {string} answer - The generated answer text
59
+ * @param {object} [options={}] - Configuration options
60
+ * @param {number} [options.jaccardWeight=0.5] - Weight for Jaccard similarity component
61
+ * @param {number} [options.coverageWeight=0.5] - Weight for query token coverage component
62
+ * @returns {{ score: number, jaccardSimilarity: number, queryTokensCovered: number, totalQueryTokens: number, missingQueryTerms: string[] }}
63
+ */
64
+ function computeAnswerRelevance(query, answer, options = {}) {
65
+ const { jaccardWeight = 0.5, coverageWeight = 0.5 } = options;
66
+
67
+ if (!query || typeof query !== 'string' || query.trim().length === 0) {
68
+ return {
69
+ score: 0,
70
+ jaccardSimilarity: 0,
71
+ queryTokensCovered: 0,
72
+ totalQueryTokens: 0,
73
+ missingQueryTerms: [],
74
+ };
75
+ }
76
+
77
+ if (!answer || typeof answer !== 'string' || answer.trim().length === 0) {
78
+ const significantQueryTokens = tokenize(query).filter(
79
+ (t) => t.length > 3 && !STOPWORDS.has(t),
80
+ );
81
+ return {
82
+ score: 0,
83
+ jaccardSimilarity: 0,
84
+ queryTokensCovered: 0,
85
+ totalQueryTokens: significantQueryTokens.length,
86
+ missingQueryTerms: [...new Set(significantQueryTokens)],
87
+ };
88
+ }
89
+
90
+ const queryTokens = tokenize(query);
91
+ const answerTokens = tokenize(answer);
92
+
93
+ // Jaccard similarity between full token sets
94
+ const jaccardSimilarity = computeJaccardSimilarity(queryTokens, answerTokens);
95
+
96
+ // Query token coverage: filter to significant query tokens (length > 3, not stopwords)
97
+ const significantQueryTokens = queryTokens.filter(
98
+ (t) => t.length > 3 && !STOPWORDS.has(t),
99
+ );
100
+
101
+ const answerTokenSet = new Set(answerTokens);
102
+ const coveredTokens = significantQueryTokens.filter((t) =>
103
+ answerTokenSet.has(t),
104
+ );
105
+ const totalQueryTokens = significantQueryTokens.length;
106
+ const queryTokensCovered = coveredTokens.length;
107
+
108
+ const coverage =
109
+ totalQueryTokens > 0 ? queryTokensCovered / totalQueryTokens : 0;
110
+
111
+ // Combined score
112
+ const score = jaccardWeight * jaccardSimilarity + coverageWeight * coverage;
113
+
114
+ // Missing query terms (unique)
115
+ const coveredSet = new Set(coveredTokens);
116
+ const missingQueryTerms = [
117
+ ...new Set(significantQueryTokens.filter((t) => !coveredSet.has(t))),
118
+ ];
119
+
120
+ return {
121
+ score,
122
+ jaccardSimilarity,
123
+ queryTokensCovered,
124
+ totalQueryTokens,
125
+ missingQueryTerms,
126
+ };
127
+ }
128
+
129
+ module.exports = { computeAnswerRelevance };
@@ -83,9 +83,36 @@ function scoreAnswer(_actual, _expected) {
83
83
  };
84
84
  }
85
85
 
86
+ /**
87
+ * Split text into sentences on sentence-ending punctuation
88
+ * @param {string} text
89
+ * @returns {string[]}
90
+ */
91
+ function splitSentences(text) {
92
+ if (!text || typeof text !== 'string') return [];
93
+ return text.split(/(?<=[.!?])\s+/).filter((s) => s.trim().length > 0);
94
+ }
95
+
96
+ /**
97
+ * Compute Jaccard similarity between two token arrays
98
+ * @param {string[]} tokensA
99
+ * @param {string[]} tokensB
100
+ * @returns {number} Similarity score between 0 and 1
101
+ */
102
+ function computeJaccardSimilarity(tokensA, tokensB) {
103
+ if (!tokensA.length || !tokensB.length) return 0;
104
+ const setA = new Set(tokensA);
105
+ const setB = new Set(tokensB);
106
+ const intersection = new Set([...setA].filter((x) => setB.has(x)));
107
+ const union = new Set([...setA, ...setB]);
108
+ return intersection.size / union.size;
109
+ }
110
+
86
111
  module.exports = {
87
112
  scoreAnswer,
88
113
  tokenize,
89
114
  computeBLEU,
90
115
  computeROUGE,
116
+ splitSentences,
117
+ computeJaccardSimilarity,
91
118
  };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { tokenize } = require('../evaluate/scoring');
5
+
6
+ /**
7
+ * Default regex patterns for heuristic entity extraction.
8
+ * @type {Object<string, RegExp>}
9
+ */
10
+ const DEFAULT_PATTERNS = {
11
+ /** Capitalized multi-word sequences (proper nouns / names) */
12
+ properNoun: /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g,
13
+ /** Dates in YYYY-MM-DD or YYYY/MM/DD format */
14
+ date: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g,
15
+ /** Numbers with units */
16
+ measurement:
17
+ /\b\d+(?:\.\d+)?\s*(?:GB|MB|KB|ms|seconds?|minutes?|hours?|days?|%)\b/gi,
18
+ /** URLs */
19
+ url: /https?:\/\/[^\s]+/g,
20
+ /** Email addresses */
21
+ email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
22
+ /** Technical terms (CamelCase or ALLCAPS) */
23
+ technical: /\b[A-Z][a-z]+[A-Z]\w+\b/g,
24
+ };
25
+
26
+ /**
27
+ * Heuristic entity extraction from text — no LLM required.
28
+ *
29
+ * Uses configurable regex patterns to identify named entities,
30
+ * dates, measurements, URLs, emails, and technical terms.
31
+ */
32
+ class EntityExtractor extends EventEmitter {
33
+ /**
34
+ * @param {Object} [options={}] - Extractor options
35
+ * @param {Object<string, RegExp>} [options.patterns] - Custom patterns to merge with defaults
36
+ */
37
+ constructor(options = {}) {
38
+ super();
39
+ this.patterns = { ...DEFAULT_PATTERNS, ...(options.patterns || {}) };
40
+ this.config = options;
41
+ }
42
+
43
+ /**
44
+ * Extract entities from a single text string.
45
+ *
46
+ * For each configured pattern type, finds all matches, deduplicates
47
+ * by normalized name, and returns an array of entity descriptors.
48
+ *
49
+ * @param {string} text - Input text to extract entities from
50
+ * @returns {Array<{name: string, type: string, positions: Array<{start: number, end: number}>, frequency: number}>}
51
+ */
52
+ extract(text) {
53
+ if (!text || typeof text !== 'string') {
54
+ return [];
55
+ }
56
+
57
+ /** @type {Map<string, {name: string, type: string, positions: Array<{start: number, end: number}>, frequency: number}>} */
58
+ const entityMap = new Map();
59
+
60
+ for (const [type, pattern] of Object.entries(this.patterns)) {
61
+ const regex = new RegExp(pattern.source, pattern.flags);
62
+ let match;
63
+
64
+ while ((match = regex.exec(text)) !== null) {
65
+ const name = match[1] || match[0];
66
+ const normalized = name.toLowerCase().trim();
67
+ const position = {
68
+ start: match.index,
69
+ end: match.index + match[0].length,
70
+ };
71
+
72
+ if (entityMap.has(normalized)) {
73
+ const existing = entityMap.get(normalized);
74
+ existing.positions.push(position);
75
+ existing.frequency += 1;
76
+ } else {
77
+ entityMap.set(normalized, {
78
+ name,
79
+ type,
80
+ positions: [position],
81
+ frequency: 1,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ const entities = [...entityMap.values()];
88
+ this.emit('extracted', { count: entities.length, entities });
89
+ return entities;
90
+ }
91
+
92
+ /**
93
+ * Extract relationships between entities that co-occur in the same sentence.
94
+ *
95
+ * @param {string} text - Input text
96
+ * @param {Array<{name: string, type: string}>} entities - Previously extracted entities
97
+ * @returns {Array<{from: string, to: string, type: string, context: string}>}
98
+ */
99
+ extractRelationships(text, entities) {
100
+ if (!text || !entities || entities.length === 0) {
101
+ return [];
102
+ }
103
+
104
+ const sentences = text
105
+ .split(/[.!?]+/)
106
+ .map((s) => s.trim())
107
+ .filter(Boolean);
108
+ const relationships = [];
109
+
110
+ for (const sentence of sentences) {
111
+ const sentenceLower = sentence.toLowerCase();
112
+ const present = entities.filter((e) =>
113
+ sentenceLower.includes(e.name.toLowerCase()),
114
+ );
115
+
116
+ for (let i = 0; i < present.length; i++) {
117
+ for (let j = i + 1; j < present.length; j++) {
118
+ relationships.push({
119
+ from: present[i].name,
120
+ to: present[j].name,
121
+ type: 'co-occurs',
122
+ context: sentence,
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ return relationships;
129
+ }
130
+
131
+ /**
132
+ * Extract entities and relationships from multiple documents.
133
+ *
134
+ * Combines entities across documents and tracks which source
135
+ * document(s) each entity originates from.
136
+ *
137
+ * @param {Array<{id: string, content: string, metadata?: Object}>} documents
138
+ * @returns {{entities: Array, relationships: Array}}
139
+ */
140
+ extractFromDocuments(documents) {
141
+ if (!Array.isArray(documents)) {
142
+ return { entities: [], relationships: [] };
143
+ }
144
+
145
+ /** @type {Map<string, {name: string, type: string, positions: Array, frequency: number, sources: string[]}>} */
146
+ const combinedEntities = new Map();
147
+ const allRelationships = [];
148
+
149
+ for (const doc of documents) {
150
+ const text = doc.content || '';
151
+ const entities = this.extract(text);
152
+
153
+ for (const entity of entities) {
154
+ const normalized = entity.name.toLowerCase().trim();
155
+ if (combinedEntities.has(normalized)) {
156
+ const existing = combinedEntities.get(normalized);
157
+ existing.frequency += entity.frequency;
158
+ existing.positions.push(...entity.positions);
159
+ if (!existing.sources.includes(doc.id)) {
160
+ existing.sources.push(doc.id);
161
+ }
162
+ } else {
163
+ combinedEntities.set(normalized, {
164
+ ...entity,
165
+ sources: [doc.id],
166
+ });
167
+ }
168
+ }
169
+
170
+ const relationships = this.extractRelationships(text, entities);
171
+ for (const rel of relationships) {
172
+ allRelationships.push({ ...rel, source: doc.id });
173
+ }
174
+ }
175
+
176
+ return {
177
+ entities: [...combinedEntities.values()],
178
+ relationships: allRelationships,
179
+ };
180
+ }
181
+ }
182
+
183
+ module.exports = { EntityExtractor, DEFAULT_PATTERNS };