@eeacms/volto-eea-chatbot 3.0.0 → 4.0.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.
@@ -1,19 +1,25 @@
1
- import middleware from './middleware';
1
+ import fetch from 'node-fetch';
2
2
  import { isPathAllowed } from '../middleware';
3
3
 
4
- jest.mock('./generative');
4
+ // Mock node-fetch
5
+ jest.mock('node-fetch');
5
6
 
6
7
  describe('halloumi middleware', () => {
7
- let req, res, next;
8
+ let req, res, next, middleware;
8
9
 
9
10
  beforeEach(() => {
11
+ fetch.mockReset();
12
+ // Clear the module cache so middleware is re-imported with current env vars
13
+ jest.resetModules();
14
+ // Re-mock after reset
15
+ jest.mock('node-fetch', () => jest.fn());
16
+
10
17
  req = {
11
18
  url: '/_ha/generate',
12
19
  method: 'POST',
13
20
  body: {
14
21
  sources: ['source1', 'source2'],
15
22
  answer: 'test answer',
16
- maxContextSegments: 3,
17
23
  },
18
24
  headers: {},
19
25
  ip: '127.0.0.1',
@@ -30,23 +36,90 @@ describe('halloumi middleware', () => {
30
36
  jest.restoreAllMocks();
31
37
  });
32
38
 
33
- it('returns error when LLMGW_TOKEN is missing', async () => {
34
- const origToken = process.env.LLMGW_TOKEN;
35
- const origUrl = process.env.LLMGW_URL;
36
- delete process.env.LLMGW_TOKEN;
37
- delete process.env.LLMGW_URL;
39
+ it('proxies request to rag-fact-checker and returns response', async () => {
40
+ process.env.RAG_FACT_CHECKER_URL = 'http://localhost:8000';
41
+ middleware = require('./middleware').default;
42
+ const mockedFetch = require('node-fetch');
43
+
44
+ const mockResponse = {
45
+ claims: [
46
+ {
47
+ startOffset: 0,
48
+ endOffset: 20,
49
+ segmentIds: ['0'],
50
+ score: 0.95,
51
+ rationale: 'Supported by source.',
52
+ },
53
+ ],
54
+ segments: { 0: { startOffset: 0, endOffset: 30 } },
55
+ };
56
+
57
+ mockedFetch.mockResolvedValue({
58
+ ok: true,
59
+ status: 200,
60
+ json: () => Promise.resolve(mockResponse),
61
+ });
62
+
63
+ await middleware(req, res, next);
64
+
65
+ expect(mockedFetch).toHaveBeenCalledWith(
66
+ 'http://localhost:8000/halloumi/generate',
67
+ expect.objectContaining({
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({
71
+ answer: 'test answer',
72
+ sources: ['source1', 'source2'],
73
+ max_context_segments: 0,
74
+ }),
75
+ }),
76
+ );
77
+ expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
78
+ expect(res.send).toHaveBeenCalledWith(mockResponse);
79
+ });
80
+
81
+ it('returns 502 when rag-fact-checker is unreachable', async () => {
82
+ process.env.RAG_FACT_CHECKER_URL = 'http://localhost:8000';
83
+ middleware = require('./middleware').default;
84
+ const mockedFetch = require('node-fetch');
85
+
86
+ mockedFetch.mockRejectedValue(new Error('ECONNREFUSED'));
38
87
 
39
88
  await middleware(req, res, next);
40
89
 
41
- expect(res.send).toHaveBeenCalledWith({
42
- error: 'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL',
90
+ expect(res.status).toHaveBeenCalledWith(502);
91
+ expect(res.send).toHaveBeenCalledWith(
92
+ expect.objectContaining({
93
+ error: expect.stringContaining('Fact-checker unavailable'),
94
+ }),
95
+ );
96
+ });
97
+
98
+ it('returns error when rag-fact-checker responds with non-ok status', async () => {
99
+ process.env.RAG_FACT_CHECKER_URL = 'http://localhost:8000';
100
+ middleware = require('./middleware').default;
101
+ const mockedFetch = require('node-fetch');
102
+
103
+ mockedFetch.mockResolvedValue({
104
+ ok: false,
105
+ status: 500,
106
+ json: () => Promise.resolve({ detail: 'Internal server error' }),
43
107
  });
44
108
 
45
- process.env.LLMGW_TOKEN = origToken;
46
- process.env.LLMGW_URL = origUrl;
109
+ await middleware(req, res, next);
110
+
111
+ expect(res.status).toHaveBeenCalledWith(500);
112
+ expect(res.send).toHaveBeenCalledWith(
113
+ expect.objectContaining({
114
+ error: expect.stringContaining('Internal server error'),
115
+ }),
116
+ );
47
117
  });
48
118
 
49
119
  it('rejects disallowed paths with 404', async () => {
120
+ process.env.RAG_FACT_CHECKER_URL = 'http://localhost:8000';
121
+ middleware = require('./middleware').default;
122
+
50
123
  req.url = '/_ha/admin/config';
51
124
  req.method = 'POST';
52
125
 
@@ -57,6 +130,9 @@ describe('halloumi middleware', () => {
57
130
  });
58
131
 
59
132
  it('rejects allowed path with wrong HTTP method', async () => {
133
+ process.env.RAG_FACT_CHECKER_URL = 'http://localhost:8000';
134
+ middleware = require('./middleware').default;
135
+
60
136
  req.url = '/_ha/generate';
61
137
  req.method = 'GET';
62
138
 
@@ -65,100 +141,29 @@ describe('halloumi middleware', () => {
65
141
  expect(res.statusCode).toBe(404);
66
142
  expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
67
143
  });
68
- });
69
-
70
- // These tests need a fresh module loaded with env vars set so that
71
- // LLMGW_TOKEN and LLMGW_URL are defined at module load time,
72
- // allowing the code to reach getVerifyClaimResponse.
73
- describe('halloumi middleware - dynamic import', () => {
74
- const buildReq = () => ({
75
- url: '/_ha/generate',
76
- method: 'POST',
77
- body: {
78
- sources: ['source1', 'source2'],
79
- answer: 'test answer',
80
- maxContextSegments: 3,
81
- },
82
- headers: {},
83
- ip: '127.0.0.1',
84
- });
85
144
 
86
- const buildRes = () => ({
87
- send: jest.fn(),
88
- set: jest.fn(),
89
- status: jest.fn().mockReturnThis(),
90
- });
91
-
92
- it('sends response on successful getVerifyClaimResponse', async () => {
93
- const origToken = process.env.LLMGW_TOKEN;
94
- const origUrl = process.env.LLMGW_URL;
95
- process.env.LLMGW_TOKEN = 'test-token';
96
- process.env.LLMGW_URL = 'http://test-url';
97
-
98
- jest.resetModules();
99
-
100
- const mockGetVerifyClaimResponse = jest
101
- .fn()
102
- .mockResolvedValue({ claims: [], segments: {} });
103
-
104
- jest.setMock('./generative', {
105
- getVerifyClaimResponse: mockGetVerifyClaimResponse,
106
- });
107
-
108
- const middlewareMod = require('./middleware').default;
109
-
110
- const req = buildReq();
111
- const res = buildRes();
112
-
113
- await middlewareMod(req, res, jest.fn());
114
-
115
- expect(mockGetVerifyClaimResponse).toHaveBeenCalled();
116
- expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
117
- expect(res.send).toHaveBeenCalledWith({ claims: [], segments: {} });
118
-
119
- process.env.LLMGW_TOKEN = origToken;
120
- process.env.LLMGW_URL = origUrl;
121
- });
145
+ it('uses default URL when RAG_FACT_CHECKER_URL is not set', async () => {
146
+ delete process.env.RAG_FACT_CHECKER_URL;
147
+ middleware = require('./middleware').default;
148
+ const mockedFetch = require('node-fetch');
122
149
 
123
- it('handles errors from getVerifyClaimResponse', async () => {
124
- const origToken = process.env.LLMGW_TOKEN;
125
- const origUrl = process.env.LLMGW_URL;
126
- process.env.LLMGW_TOKEN = 'test-token';
127
- process.env.LLMGW_URL = 'http://test-url';
128
-
129
- jest.resetModules();
130
-
131
- const mockGetVerifyClaimResponse = jest
132
- .fn()
133
- .mockRejectedValue(new Error('LLM error'));
134
-
135
- jest.setMock('./generative', {
136
- getVerifyClaimResponse: mockGetVerifyClaimResponse,
150
+ mockedFetch.mockResolvedValue({
151
+ ok: true,
152
+ status: 200,
153
+ json: () => Promise.resolve({ claims: [], segments: {} }),
137
154
  });
138
155
 
139
- const middlewareMod = require('./middleware').default;
140
-
141
- const req = buildReq();
142
- const res = buildRes();
143
-
144
- await middlewareMod(req, res, jest.fn());
156
+ await middleware(req, res, next);
145
157
 
146
- expect(res.status).toHaveBeenCalledWith(500);
147
- expect(res.send).toHaveBeenCalledWith(
148
- expect.objectContaining({ error: expect.stringContaining('LLM error') }),
158
+ expect(mockedFetch).toHaveBeenCalledWith(
159
+ 'http://localhost:8000/halloumi/generate',
160
+ expect.any(Object),
149
161
  );
150
-
151
- process.env.LLMGW_TOKEN = origToken;
152
- process.env.LLMGW_URL = origUrl;
153
162
  });
154
163
  });
155
164
 
156
165
  describe('halloumi path allowlist', () => {
157
- // Mirror of ALLOWED_HALLOUMI_PATHS from middleware.js
158
- const ALLOWED_HALLOUMI_PATHS = [
159
- { path: '/generate', methods: ['POST'] },
160
- { path: '/classify', methods: ['POST'] },
161
- ];
166
+ const ALLOWED_HALLOUMI_PATHS = [{ path: '/generate', methods: ['POST'] }];
162
167
 
163
168
  it('allows /generate with POST', () => {
164
169
  expect(isPathAllowed('/generate', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
@@ -166,12 +171,6 @@ describe('halloumi path allowlist', () => {
166
171
  );
167
172
  });
168
173
 
169
- it('allows /classify with POST', () => {
170
- expect(isPathAllowed('/classify', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
171
- true,
172
- );
173
- });
174
-
175
174
  it('rejects /generate with wrong method', () => {
176
175
  expect(isPathAllowed('/generate', 'GET', ALLOWED_HALLOUMI_PATHS)).toBe(
177
176
  false,
package/src/middleware.js CHANGED
@@ -289,7 +289,7 @@ export default async function middleware(req, res, next) {
289
289
 
290
290
  const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
291
291
 
292
- const api_key = process.env.ONYX_API_KEY;
292
+ const api_key = process.env.ONYX_API_KEY; //betterleaks:allow
293
293
  if (!api_key) {
294
294
  res.statusCode = 500;
295
295
  res.statusMessage = MSG_INVALID_CONFIGURATION;
@@ -300,7 +300,7 @@ export default async function middleware(req, res, next) {
300
300
  try {
301
301
  await send_onyx_request(req, res, {
302
302
  url: reqUrl,
303
- api_key: api_key,
303
+ api_key: api_key, //betterleaks:allow
304
304
  is_related_question,
305
305
  });
306
306
  } catch (error) {
@@ -89,7 +89,7 @@ describe('src/middleware', () => {
89
89
  });
90
90
 
91
91
  it('proxies POST request with api_key and pipes response', async () => {
92
- process.env.ONYX_API_KEY = 'test-key';
92
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
93
93
  process.env.ONYX_URL = 'http://localhost:3000';
94
94
 
95
95
  await middleware(req, res, next);
@@ -110,7 +110,7 @@ describe('src/middleware', () => {
110
110
  });
111
111
 
112
112
  it('handles related question paths', async () => {
113
- process.env.ONYX_API_KEY = 'test-key';
113
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
114
114
  process.env.ONYX_URL = 'http://localhost:3000';
115
115
  req.url = '/_rq/chat/send-message';
116
116
 
@@ -124,7 +124,7 @@ describe('src/middleware', () => {
124
124
  });
125
125
 
126
126
  it('sends error response when fetch throws', async () => {
127
- process.env.ONYX_API_KEY = 'test-key';
127
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
128
128
  process.env.ONYX_URL = 'http://localhost:3000';
129
129
 
130
130
  nodeFetch.mockRejectedValueOnce(
@@ -145,7 +145,7 @@ describe('src/middleware', () => {
145
145
  });
146
146
 
147
147
  it('handles error without response text', async () => {
148
- process.env.ONYX_API_KEY = 'test-key';
148
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
149
149
  process.env.ONYX_URL = 'http://localhost:3000';
150
150
 
151
151
  nodeFetch.mockRejectedValueOnce(new Error('No response'));
@@ -162,7 +162,7 @@ describe('src/middleware', () => {
162
162
  });
163
163
 
164
164
  it('handles GET requests without body', async () => {
165
- process.env.ONYX_API_KEY = 'test-key';
165
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
166
166
  process.env.ONYX_URL = 'http://localhost:3000';
167
167
  req.method = 'GET';
168
168
  req.url = '/_da/persona/1';
@@ -175,7 +175,7 @@ describe('src/middleware', () => {
175
175
  });
176
176
 
177
177
  it('uses mock create-chat-session when MOCK_LLM_FILE_PATH is set', async () => {
178
- process.env.ONYX_API_KEY = 'test-key';
178
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
179
179
  process.env.ONYX_URL = 'http://localhost:3000';
180
180
  process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
181
181
  req.url = '/_da/chat/create-chat-session';
@@ -189,7 +189,7 @@ describe('src/middleware', () => {
189
189
  });
190
190
 
191
191
  it('uses mock send-message with stream when MOCK_LLM_FILE_PATH is set', async () => {
192
- process.env.ONYX_API_KEY = 'test-key';
192
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
193
193
  process.env.ONYX_URL = 'http://localhost:3000';
194
194
  process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
195
195
  req.url = '/_da/chat/send-message';
@@ -223,7 +223,7 @@ describe('src/middleware', () => {
223
223
  });
224
224
 
225
225
  it('dumps LLM response when DUMP_LLM_FILE_PATH is set', async () => {
226
- process.env.ONYX_API_KEY = 'test-key';
226
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
227
227
  process.env.ONYX_URL = 'http://localhost:3000';
228
228
  process.env.DUMP_LLM_FILE_PATH = '/tmp/dumped_response.jsonl';
229
229
 
@@ -236,7 +236,7 @@ describe('src/middleware', () => {
236
236
  });
237
237
 
238
238
  it('rejects disallowed paths with 404', async () => {
239
- process.env.ONYX_API_KEY = 'test-key';
239
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
240
240
  process.env.ONYX_URL = 'http://localhost:3000';
241
241
  req.url = '/_da/admin/users/delete';
242
242
  req.method = 'POST';
@@ -249,7 +249,7 @@ describe('src/middleware', () => {
249
249
  });
250
250
 
251
251
  it('rejects allowed path with wrong HTTP method', async () => {
252
- process.env.ONYX_API_KEY = 'test-key';
252
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
253
253
  process.env.ONYX_URL = 'http://localhost:3000';
254
254
  req.url = '/_da/persona';
255
255
  req.method = 'DELETE';
@@ -262,7 +262,7 @@ describe('src/middleware', () => {
262
262
  });
263
263
 
264
264
  it('rejects path traversal attempts', async () => {
265
- process.env.ONYX_API_KEY = 'test-key';
265
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
266
266
  process.env.ONYX_URL = 'http://localhost:3000';
267
267
  req.url = '/_da/../../etc/passwd';
268
268
  req.method = 'GET';
@@ -1,149 +0,0 @@
1
- import debug from 'debug';
2
- import fetch from 'node-fetch';
3
-
4
- const log = debug('halloumi');
5
-
6
- const LLMGW_URL = process.env.LLMGW_URL;
7
- const LLMGW_API_KEY = process.env.LLMGW_TOKEN;
8
- const MIN_CONTEXT_SENTENCES_FOR_FILTERING = 75;
9
-
10
- const filterModel = {
11
- name: 'Inhouse-LLM/gpt-oss-120b',
12
- apiUrl: `${LLMGW_URL}/chat/completions`,
13
- apiKey: LLMGW_API_KEY,
14
- };
15
-
16
- export async function callLLM(apiUrl, apiKey, requestBody, { ip } = {}) {
17
- const headers = {
18
- 'Content-Type': 'application/json',
19
- accept: 'application/json',
20
- };
21
- if (apiKey) {
22
- headers['Authorization'] = `Bearer ${apiKey}`;
23
- }
24
- if (ip) {
25
- headers['X-Forwarded-For'] = ip;
26
- }
27
-
28
- const response = await fetch(apiUrl, {
29
- method: 'POST',
30
- headers,
31
- body: JSON.stringify(requestBody),
32
- });
33
-
34
- return response.json();
35
- }
36
-
37
- function buildClaimFilterPrompt(sentences) {
38
- const numberedSentences = sentences
39
- .map((s, i) => `${i + 1}. "${s.trim()}"`)
40
- .join('\n');
41
-
42
- return `Given the following numbered sentences, identify which ones are NOT verifiable factual claims.
43
-
44
- Exclude sentences that are:
45
- - Greetings, preambles, or transitional phrases
46
- - Opinions, vague statements, or subjective assessments
47
- - Introductory sentences that set up a list or table
48
- - Sentences that merely restate or paraphrase the question
49
- - Conversational closers (e.g., "let me know if you need more")
50
- - Broad summaries that don't assert a specific fact
51
- - Disclaimers or meta-commentary about the response itself
52
- - Sentences that do not provide enough information to verify
53
- - Sentences that only mention a concept without providing specific information
54
-
55
- Respond with ONLY a comma-separated list of the sentence numbers to exclude. If none should be excluded, respond with "NONE".
56
-
57
- Sentences:
58
- ${numberedSentences}`;
59
- }
60
-
61
- function buildContextFilterPrompt(contextSentences, claimSentences) {
62
- const numberedContext = contextSentences
63
- .map((s, i) => `${i + 1}. "${s.trim()}"`)
64
- .join('\n');
65
- const numberedClaims = claimSentences
66
- .map((s, i) => `${i + 1}. "${s.trim()}"`)
67
- .join('\n');
68
-
69
- return `Given the following context sentences and claims, identify which context sentences are NOT relevant to verifying ANY of the claims.
70
-
71
- Context sentences:
72
- ${numberedContext}
73
-
74
- Claims to verify:
75
- ${numberedClaims}
76
-
77
- Respond with ONLY a comma-separated list of the context sentence numbers that are NOT relevant. If all are relevant, respond with "NONE".`;
78
- }
79
-
80
- /**
81
- * Parses a comma-separated list of indices from an LLM response.
82
- */
83
- export function parseExcludeIndices(content, maxIndex) {
84
- const excludeIndices = new Set();
85
- if (content.trim().toUpperCase() === 'NONE') {
86
- return excludeIndices;
87
- }
88
- const matches = content.match(/\d+/g) || [];
89
- for (const match of matches) {
90
- const idx = parseInt(match, 10);
91
- if (idx >= 1 && idx <= maxIndex) {
92
- excludeIndices.add(idx);
93
- }
94
- }
95
- return excludeIndices;
96
- }
97
-
98
- async function callFilterModel(prompt, { ip } = {}) {
99
- const data = {
100
- messages: [{ role: 'user', content: prompt }],
101
- temperature: 0.0,
102
- model: filterModel.name,
103
- };
104
- const jsonData = await callLLM(filterModel.apiUrl, filterModel.apiKey, data, {
105
- ip,
106
- });
107
- return jsonData.choices?.[0]?.message?.content || '';
108
- }
109
-
110
- export async function excludeClaimSentences(sentences, { ip } = {}) {
111
- if (sentences.length === 0) {
112
- return new Set();
113
- }
114
-
115
- try {
116
- const prompt = buildClaimFilterPrompt(sentences);
117
- const content = await callFilterModel(prompt, { ip });
118
- const excludedIndices = parseExcludeIndices(content, sentences.length);
119
- log('Claim filter response', excludedIndices.size);
120
- return excludedIndices;
121
- } catch (error) {
122
- log('Claim filter failed, skipping', error);
123
- return new Set();
124
- }
125
- }
126
-
127
- export async function excludeContextSentences(
128
- contextSentences,
129
- claimSentences,
130
- { ip } = {},
131
- ) {
132
- if (contextSentences.length <= MIN_CONTEXT_SENTENCES_FOR_FILTERING) {
133
- return new Set();
134
- }
135
-
136
- try {
137
- const prompt = buildContextFilterPrompt(contextSentences, claimSentences);
138
- const content = await callFilterModel(prompt, { ip });
139
- const excludedIndices = parseExcludeIndices(
140
- content,
141
- contextSentences.length,
142
- );
143
- log('Context filter response', excludedIndices.size);
144
- return excludedIndices;
145
- } catch (error) {
146
- log('Context filter failed, skipping', error);
147
- return new Set();
148
- }
149
- }