@eeacms/volto-eea-chatbot 3.0.1 → 4.1.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/CHANGELOG.md +53 -4
- package/README.md +115 -12
- package/package.json +1 -1
- package/src/ChatBlock/ChatBlockView.jsx +7 -1
- package/src/ChatBlock/chat/AIMessage.tsx +79 -45
- package/src/ChatBlock/chat/ChatMessage.tsx +17 -10
- package/src/ChatBlock/chat/ChatWindow.tsx +11 -0
- package/src/ChatBlock/chat/MessageContext.ts +12 -0
- package/src/ChatBlock/chat/index.ts +1 -0
- package/src/ChatBlock/components/markdown/ClaimModal.jsx +3 -3
- package/src/ChatBlock/components/markdown/index.jsx +1 -0
- package/src/ChatBlock/hooks/useQualityMarkers.js +21 -4
- package/src/ChatBlock/index.js +9 -1
- package/src/ChatBlock/packets/RendererComponent.tsx +9 -0
- package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +15 -3
- package/src/ChatBlock/schema.jsx +13 -0
- package/src/ChatBlock/services/messageProcessor.ts +9 -12
- package/src/ChatBlock/types/interfaces.ts +25 -0
- package/src/ChatBlock/utils/index.tsx +10 -0
- package/src/ChatBlock/variations.ts +44 -0
- package/src/halloumi/README.md +27 -1
- package/src/halloumi/middleware.js +41 -50
- package/src/halloumi/middleware.test.js +102 -103
- package/src/halloumi/filtering.js +0 -149
- package/src/halloumi/filtering.test.js +0 -242
- package/src/halloumi/generative.js +0 -324
- package/src/halloumi/generative.test.js +0 -386
- package/src/halloumi/markdown-splitter.js +0 -172
- package/src/halloumi/markdown-splitter.test.js +0 -133
- package/src/halloumi/postprocessing.js +0 -247
- package/src/halloumi/postprocessing.test.js +0 -441
- package/src/halloumi/preprocessing.js +0 -117
- package/src/halloumi/preprocessing.test.js +0 -184
|
@@ -1,19 +1,25 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
2
|
import { isPathAllowed } from '../middleware';
|
|
3
3
|
|
|
4
|
-
|
|
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('
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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'; //betterleaks:allow
|
|
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; //betterleaks:allow
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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(
|
|
147
|
-
|
|
148
|
-
expect.
|
|
158
|
+
expect(mockedFetch).toHaveBeenCalledWith(
|
|
159
|
+
'http://localhost:8000/halloumi/generate',
|
|
160
|
+
expect.any(Object),
|
|
149
161
|
);
|
|
150
|
-
|
|
151
|
-
process.env.LLMGW_TOKEN = origToken; //betterleaks:allow
|
|
152
|
-
process.env.LLMGW_URL = origUrl;
|
|
153
162
|
});
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
describe('halloumi path allowlist', () => {
|
|
157
|
-
|
|
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,
|
|
@@ -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; //betterleaks:allow
|
|
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, //betterleaks:allow
|
|
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
|
-
}
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import fetch from 'node-fetch';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
parseExcludeIndices,
|
|
5
|
-
callLLM,
|
|
6
|
-
excludeClaimSentences,
|
|
7
|
-
excludeContextSentences,
|
|
8
|
-
} from './filtering';
|
|
9
|
-
jest.mock('node-fetch');
|
|
10
|
-
|
|
11
|
-
describe('parseExcludeIndices', () => {
|
|
12
|
-
it('parses single indices', () => {
|
|
13
|
-
const result = parseExcludeIndices('1,3,5', 6);
|
|
14
|
-
expect(result).toEqual(new Set([1, 3, 5]));
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('returns empty set for NONE', () => {
|
|
18
|
-
const result = parseExcludeIndices('NONE', 10);
|
|
19
|
-
expect(result).toEqual(new Set());
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('returns empty set for none (lowercase)', () => {
|
|
23
|
-
const result = parseExcludeIndices(' none ', 10);
|
|
24
|
-
expect(result).toEqual(new Set());
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('ignores indices below 1', () => {
|
|
28
|
-
const result = parseExcludeIndices('0, 1, 3', 5);
|
|
29
|
-
expect(result).toEqual(new Set([1, 3]));
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('ignores indices above maxIndex', () => {
|
|
33
|
-
const result = parseExcludeIndices('1, 3, 99', 5);
|
|
34
|
-
expect(result).toEqual(new Set([1, 3]));
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('handles whitespace variations', () => {
|
|
38
|
-
const result = parseExcludeIndices(' 1 , 5 , 7 ', 10);
|
|
39
|
-
expect(result).toEqual(new Set([1, 5, 7]));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('extracts numbers even from unexpected formats', () => {
|
|
43
|
-
// Parser uses match(/\d+/g) so it extracts all numbers
|
|
44
|
-
const result = parseExcludeIndices('1-3, 5', 10);
|
|
45
|
-
expect(result).toEqual(new Set([1, 3, 5]));
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('returns empty set for empty string with no numbers', () => {
|
|
49
|
-
const result = parseExcludeIndices('no numbers here', 10);
|
|
50
|
-
expect(result).toEqual(new Set());
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe('callLLM', () => {
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
fetch.mockReset();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
fetch.mockReset();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('makes POST request with correct headers', async () => {
|
|
64
|
-
const mockResponse = { choices: [{ message: { content: 'result' } }] };
|
|
65
|
-
fetch.mockResolvedValue({
|
|
66
|
-
json: () => Promise.resolve(mockResponse),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
await callLLM('https://api.example.com', 'my-api-key', { model: 'gpt-4' });
|
|
70
|
-
|
|
71
|
-
expect(fetch).toHaveBeenCalledWith(
|
|
72
|
-
'https://api.example.com',
|
|
73
|
-
expect.objectContaining({
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: expect.objectContaining({
|
|
76
|
-
'Content-Type': 'application/json',
|
|
77
|
-
Authorization: 'Bearer my-api-key',
|
|
78
|
-
}),
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('omits Authorization header when no apiKey', async () => {
|
|
84
|
-
fetch.mockResolvedValue({
|
|
85
|
-
json: () => Promise.resolve({}),
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
await callLLM('https://api.example.com', null, { model: 'gpt-4' });
|
|
89
|
-
|
|
90
|
-
const headers = fetch.mock.calls[0][1].headers;
|
|
91
|
-
expect(headers['Authorization']).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('adds X-Forwarded-For header when ip is provided', async () => {
|
|
95
|
-
fetch.mockResolvedValue({
|
|
96
|
-
json: () => Promise.resolve({}),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
await callLLM('https://api.example.com', null, {}, { ip: '1.2.3.4' });
|
|
100
|
-
|
|
101
|
-
const headers = fetch.mock.calls[0][1].headers;
|
|
102
|
-
expect(headers['X-Forwarded-For']).toBe('1.2.3.4');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('returns parsed JSON response', async () => {
|
|
106
|
-
const mockResponse = { choices: [{ message: { content: 'hello' } }] };
|
|
107
|
-
fetch.mockResolvedValue({
|
|
108
|
-
json: () => Promise.resolve(mockResponse),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const result = await callLLM('https://api.example.com', null, {});
|
|
112
|
-
expect(result).toEqual(mockResponse);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('excludeClaimSentences', () => {
|
|
117
|
-
beforeEach(() => {
|
|
118
|
-
fetch.mockReset();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
afterEach(() => {
|
|
122
|
-
fetch.mockReset();
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('returns empty set for empty sentences array', async () => {
|
|
126
|
-
const result = await excludeClaimSentences([]);
|
|
127
|
-
expect(result).toEqual(new Set());
|
|
128
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('calls LLM and returns excluded sentence indices', async () => {
|
|
132
|
-
fetch.mockResolvedValue({
|
|
133
|
-
json: () =>
|
|
134
|
-
Promise.resolve({
|
|
135
|
-
choices: [{ message: { content: '1,3' } }],
|
|
136
|
-
}),
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const sentences = ['Sentence one.', 'Sentence two.', 'Sentence three.'];
|
|
140
|
-
const result = await excludeClaimSentences(sentences);
|
|
141
|
-
|
|
142
|
-
expect(result).toEqual(new Set([1, 3]));
|
|
143
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('returns empty set when LLM returns NONE', async () => {
|
|
147
|
-
fetch.mockResolvedValue({
|
|
148
|
-
json: () =>
|
|
149
|
-
Promise.resolve({
|
|
150
|
-
choices: [{ message: { content: 'NONE' } }],
|
|
151
|
-
}),
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const sentences = ['Some factual sentence.', 'Another factual sentence.'];
|
|
155
|
-
const result = await excludeClaimSentences(sentences);
|
|
156
|
-
|
|
157
|
-
expect(result).toEqual(new Set());
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('returns empty set on LLM error', async () => {
|
|
161
|
-
fetch.mockRejectedValue(new Error('Network error'));
|
|
162
|
-
|
|
163
|
-
const sentences = ['Sentence one.'];
|
|
164
|
-
const result = await excludeClaimSentences(sentences);
|
|
165
|
-
|
|
166
|
-
expect(result).toEqual(new Set());
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('passes ip option to LLM call', async () => {
|
|
170
|
-
fetch.mockResolvedValue({
|
|
171
|
-
json: () =>
|
|
172
|
-
Promise.resolve({ choices: [{ message: { content: 'NONE' } }] }),
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
await excludeClaimSentences(['test sentence'], { ip: '10.0.0.1' });
|
|
176
|
-
|
|
177
|
-
const headers = fetch.mock.calls[0][1].headers;
|
|
178
|
-
expect(headers['X-Forwarded-For']).toBe('10.0.0.1');
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('excludeContextSentences', () => {
|
|
183
|
-
const MIN_SENTENCES = 75;
|
|
184
|
-
|
|
185
|
-
beforeEach(() => {
|
|
186
|
-
fetch.mockReset();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
afterEach(() => {
|
|
190
|
-
fetch.mockReset();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('returns empty set when context sentences <= minimum threshold', async () => {
|
|
194
|
-
const shortContext = Array(10).fill('Short sentence.');
|
|
195
|
-
const claims = ['A claim.'];
|
|
196
|
-
const result = await excludeContextSentences(shortContext, claims);
|
|
197
|
-
|
|
198
|
-
expect(result).toEqual(new Set());
|
|
199
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('calls LLM when context exceeds minimum threshold', async () => {
|
|
203
|
-
fetch.mockResolvedValue({
|
|
204
|
-
json: () =>
|
|
205
|
-
Promise.resolve({
|
|
206
|
-
choices: [{ message: { content: '1,2' } }],
|
|
207
|
-
}),
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
|
|
211
|
-
const claims = ['A claim.'];
|
|
212
|
-
const result = await excludeContextSentences(longContext, claims);
|
|
213
|
-
|
|
214
|
-
expect(result).toEqual(new Set([1, 2]));
|
|
215
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('returns empty set on LLM error', async () => {
|
|
219
|
-
fetch.mockRejectedValue(new Error('timeout'));
|
|
220
|
-
|
|
221
|
-
const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
|
|
222
|
-
const claims = ['A claim.'];
|
|
223
|
-
const result = await excludeContextSentences(longContext, claims);
|
|
224
|
-
|
|
225
|
-
expect(result).toEqual(new Set());
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('returns empty set when LLM returns NONE for context', async () => {
|
|
229
|
-
fetch.mockResolvedValue({
|
|
230
|
-
json: () =>
|
|
231
|
-
Promise.resolve({
|
|
232
|
-
choices: [{ message: { content: 'NONE' } }],
|
|
233
|
-
}),
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
|
|
237
|
-
const claims = ['A claim.'];
|
|
238
|
-
const result = await excludeContextSentences(longContext, claims);
|
|
239
|
-
|
|
240
|
-
expect(result).toEqual(new Set());
|
|
241
|
-
});
|
|
242
|
-
});
|