@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.
- package/.github/workflows/betterleaks.yml +133 -0
- package/.gitleaks.toml +89 -0
- package/CHANGELOG.md +32 -1
- package/README.md +102 -12
- package/RELEASE.md +1 -1
- package/package.json +1 -1
- package/src/ChatBlock/chat/AIMessage.tsx +60 -41
- package/src/ChatBlock/components/markdown/ClaimModal.jsx +3 -3
- package/src/ChatBlock/components/markdown/index.jsx +1 -0
- package/src/ChatBlock/hooks/useQualityMarkers.js +11 -2
- package/src/ChatBlock/packets/MultiToolRenderer.tsx +20 -11
- package/src/ChatBlock/services/messageProcessor.ts +36 -14
- package/src/ChatBlock/style.less +41 -51
- package/src/ChatBlock/utils/index.tsx +10 -0
- package/src/halloumi/README.md +27 -1
- package/src/halloumi/middleware.js +40 -50
- package/src/halloumi/middleware.test.js +102 -103
- package/src/middleware.js +2 -2
- package/src/middleware.test.js +11 -11
- 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
|
@@ -133,7 +133,14 @@ export function MultiToolRenderer({
|
|
|
133
133
|
{filteredToolGroups.map((toolGroup, index) => {
|
|
134
134
|
const isLastItem = index === filteredToolGroups.length - 1;
|
|
135
135
|
const toolState = toolStates.get(toolGroup.ind);
|
|
136
|
-
const isToolCompleted = toolState?.isCompleted;
|
|
136
|
+
const isToolCompleted = toolState?.isCompleted === true;
|
|
137
|
+
const isToolActive =
|
|
138
|
+
isOverallStreaming && !isToolCompleted && isLastItem;
|
|
139
|
+
const toolDisplayState = isToolActive
|
|
140
|
+
? 'active'
|
|
141
|
+
: isToolCompleted
|
|
142
|
+
? 'completed'
|
|
143
|
+
: 'pending';
|
|
137
144
|
|
|
138
145
|
return (
|
|
139
146
|
<div
|
|
@@ -141,6 +148,8 @@ export function MultiToolRenderer({
|
|
|
141
148
|
className={cx({
|
|
142
149
|
'tool-collapsed-wrapper': isOverallStreaming,
|
|
143
150
|
})}
|
|
151
|
+
data-state={toolDisplayState}
|
|
152
|
+
aria-current={isToolActive ? 'step' : undefined}
|
|
144
153
|
>
|
|
145
154
|
<RendererComponent
|
|
146
155
|
packets={toolGroup.packets}
|
|
@@ -158,12 +167,7 @@ export function MultiToolRenderer({
|
|
|
158
167
|
const finalIcon = icon ? (
|
|
159
168
|
React.createElement(icon, { size: 14 })
|
|
160
169
|
) : (
|
|
161
|
-
<span
|
|
162
|
-
className={cx({
|
|
163
|
-
'tool-icon-dot': isOverallStreaming,
|
|
164
|
-
'tool-icon-default': !isOverallStreaming,
|
|
165
|
-
})}
|
|
166
|
-
/>
|
|
170
|
+
<span className="tool-icon-dot" />
|
|
167
171
|
);
|
|
168
172
|
|
|
169
173
|
// If tool is not completed and we are overall streaming, show collapsed view
|
|
@@ -184,14 +188,14 @@ export function MultiToolRenderer({
|
|
|
184
188
|
return (
|
|
185
189
|
<div
|
|
186
190
|
className={cx('tool-item-collapsed', {
|
|
187
|
-
active:
|
|
191
|
+
active: isToolActive,
|
|
188
192
|
completed: isToolCompleted,
|
|
189
193
|
})}
|
|
190
194
|
>
|
|
191
195
|
<div className="tool-collapsed-icon">
|
|
192
196
|
{finalIcon}
|
|
193
197
|
</div>
|
|
194
|
-
<span className="tool-collapsed-status">
|
|
198
|
+
<span className="tool-status tool-collapsed-status">
|
|
195
199
|
{status}
|
|
196
200
|
</span>
|
|
197
201
|
</div>
|
|
@@ -199,9 +203,14 @@ export function MultiToolRenderer({
|
|
|
199
203
|
}
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
//
|
|
206
|
+
// Detailed view for streaming reasoning/search tools and completed tools
|
|
203
207
|
return (
|
|
204
|
-
<div
|
|
208
|
+
<div
|
|
209
|
+
className={cx('tool-item-expanded', {
|
|
210
|
+
active: isToolActive,
|
|
211
|
+
completed: isToolCompleted,
|
|
212
|
+
})}
|
|
213
|
+
>
|
|
205
214
|
<div className="tool-connector-line" />
|
|
206
215
|
|
|
207
216
|
<div className="tool-item-row">
|
|
@@ -33,6 +33,7 @@ export class MessageProcessor {
|
|
|
33
33
|
private _errorContent: string = '';
|
|
34
34
|
private _documents: OnyxDocument[] = [];
|
|
35
35
|
private _citations = new Map<number, string>();
|
|
36
|
+
private _citationsAreFallback: boolean = false;
|
|
36
37
|
private _isComplete: boolean = false;
|
|
37
38
|
private _isFinalMessageComing: boolean = false;
|
|
38
39
|
|
|
@@ -208,20 +209,29 @@ export class MessageProcessor {
|
|
|
208
209
|
|
|
209
210
|
if (newDocuments) {
|
|
210
211
|
this._documents = Array.from(this.documentMap.values());
|
|
212
|
+
}
|
|
211
213
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
}
|
|
214
|
+
// If we have final_documents and no citations yet, create a fallback mapping.
|
|
215
|
+
// This ensures the Sources tab shows up in v3 when citation_info is missing.
|
|
216
|
+
// The fallback is cleared in processCitations() when real citation_info arrives.
|
|
217
|
+
if (
|
|
218
|
+
packet.obj.type === PacketType.MESSAGE_START &&
|
|
219
|
+
data.final_documents &&
|
|
220
|
+
this._citations.size === 0
|
|
221
|
+
) {
|
|
222
|
+
data.final_documents.forEach((doc: OnyxDocument, index: number) => {
|
|
223
|
+
if (doc.document_id) {
|
|
224
|
+
this._citations.set(index + 1, doc.document_id);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
this._citationsAreFallback = this._citations.size > 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private clearFallbackCitations(): void {
|
|
232
|
+
if (this._citationsAreFallback) {
|
|
233
|
+
this._citations.clear();
|
|
234
|
+
this._citationsAreFallback = false;
|
|
225
235
|
}
|
|
226
236
|
}
|
|
227
237
|
|
|
@@ -231,6 +241,10 @@ export class MessageProcessor {
|
|
|
231
241
|
*/
|
|
232
242
|
private processCitations(packet: Packet) {
|
|
233
243
|
if (packet.obj.type === PacketType.CITATION_INFO) {
|
|
244
|
+
// First real citation_info arrived — discard the fallback mapping
|
|
245
|
+
// (which mapped ALL final_documents) and start fresh with actual citations.
|
|
246
|
+
this.clearFallbackCitations();
|
|
247
|
+
|
|
234
248
|
const citationInfo = packet.obj as any;
|
|
235
249
|
if (citationInfo.citation_number && citationInfo.document_id) {
|
|
236
250
|
this._citations.set(
|
|
@@ -244,7 +258,14 @@ export class MessageProcessor {
|
|
|
244
258
|
return;
|
|
245
259
|
}
|
|
246
260
|
const citationDelta = packet.obj as CitationDelta;
|
|
247
|
-
citationDelta.citations
|
|
261
|
+
const explicitCitations = (citationDelta.citations || []).filter(
|
|
262
|
+
(citation: StreamingCitation) =>
|
|
263
|
+
citation.citation_num != null && Boolean(citation.document_id),
|
|
264
|
+
);
|
|
265
|
+
if (explicitCitations.length > 0) {
|
|
266
|
+
this.clearFallbackCitations();
|
|
267
|
+
}
|
|
268
|
+
explicitCitations.forEach((citation: StreamingCitation) => {
|
|
248
269
|
if (!this._citations.has(citation.citation_num)) {
|
|
249
270
|
this._citations.set(citation.citation_num, citation.document_id);
|
|
250
271
|
}
|
|
@@ -397,6 +418,7 @@ export class MessageProcessor {
|
|
|
397
418
|
this._errorContent = '';
|
|
398
419
|
this._documents = [];
|
|
399
420
|
this._citations.clear();
|
|
421
|
+
this._citationsAreFallback = false;
|
|
400
422
|
this._isComplete = false;
|
|
401
423
|
this._isFinalMessageComing = false;
|
|
402
424
|
}
|
package/src/ChatBlock/style.less
CHANGED
|
@@ -1604,14 +1604,6 @@ mark {
|
|
|
1604
1604
|
}
|
|
1605
1605
|
}
|
|
1606
1606
|
|
|
1607
|
-
.tool-icon-default {
|
|
1608
|
-
display: inline-block;
|
|
1609
|
-
width: 0.5rem;
|
|
1610
|
-
height: 0.5rem;
|
|
1611
|
-
border-radius: 50%;
|
|
1612
|
-
background-color: currentColor;
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
1607
|
// Content column
|
|
1616
1608
|
.tool-content {
|
|
1617
1609
|
flex: 1;
|
|
@@ -1625,17 +1617,6 @@ mark {
|
|
|
1625
1617
|
display: flex;
|
|
1626
1618
|
}
|
|
1627
1619
|
|
|
1628
|
-
.tool-status {
|
|
1629
|
-
display: flex;
|
|
1630
|
-
align-items: center;
|
|
1631
|
-
border-radius: 12px;
|
|
1632
|
-
margin-bottom: 0.25rem;
|
|
1633
|
-
color: @teal;
|
|
1634
|
-
font-size: 0.875rem;
|
|
1635
|
-
font-weight: 500;
|
|
1636
|
-
gap: 0.25rem;
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
1620
|
.tool-text {
|
|
1640
1621
|
color: #6b7280;
|
|
1641
1622
|
font-size: 0.75rem;
|
|
@@ -1651,6 +1632,47 @@ mark {
|
|
|
1651
1632
|
.multi-tool-renderer {
|
|
1652
1633
|
margin-bottom: 1em;
|
|
1653
1634
|
|
|
1635
|
+
// Shared tool state styles
|
|
1636
|
+
.tool-icon-dot {
|
|
1637
|
+
display: inline-block;
|
|
1638
|
+
width: 0.5rem;
|
|
1639
|
+
height: 0.5rem;
|
|
1640
|
+
border-radius: 50%;
|
|
1641
|
+
background-color: currentColor;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
.tool-status {
|
|
1645
|
+
display: flex;
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
border-radius: 12px;
|
|
1648
|
+
margin-bottom: 0.25rem;
|
|
1649
|
+
color: @teal;
|
|
1650
|
+
font-size: 0.875rem;
|
|
1651
|
+
font-weight: 500;
|
|
1652
|
+
gap: 0.25rem;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
.tool-item-expanded.active .tool-icon-circle,
|
|
1656
|
+
.tool-item-collapsed.active .tool-collapsed-icon {
|
|
1657
|
+
color: @teal;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
.active .tool-status {
|
|
1661
|
+
animation: shimmer 2.5s linear infinite;
|
|
1662
|
+
background: linear-gradient(
|
|
1663
|
+
90deg,
|
|
1664
|
+
@teal 0%,
|
|
1665
|
+
@teal 40%,
|
|
1666
|
+
rgba(0, 179, 174, 0.5) 50%,
|
|
1667
|
+
@teal 60%,
|
|
1668
|
+
@teal 100%
|
|
1669
|
+
);
|
|
1670
|
+
-webkit-background-clip: text;
|
|
1671
|
+
background-clip: text;
|
|
1672
|
+
background-size: 200% 100%;
|
|
1673
|
+
-webkit-text-fill-color: transparent;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1654
1676
|
// Common header styles for both collapsed and summary headers
|
|
1655
1677
|
.tools-collapsed-header,
|
|
1656
1678
|
.tools-summary-header {
|
|
@@ -1766,29 +1788,6 @@ mark {
|
|
|
1766
1788
|
}
|
|
1767
1789
|
}
|
|
1768
1790
|
|
|
1769
|
-
&.active {
|
|
1770
|
-
.tool-collapsed-icon {
|
|
1771
|
-
position: relative;
|
|
1772
|
-
color: @teal;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
.tool-collapsed-status {
|
|
1776
|
-
animation: shimmer 2.5s linear infinite;
|
|
1777
|
-
background: linear-gradient(
|
|
1778
|
-
90deg,
|
|
1779
|
-
@teal 0%,
|
|
1780
|
-
@teal 40%,
|
|
1781
|
-
rgba(0, 179, 174, 0.5) 50%,
|
|
1782
|
-
@teal 60%,
|
|
1783
|
-
@teal 100%
|
|
1784
|
-
);
|
|
1785
|
-
-webkit-background-clip: text;
|
|
1786
|
-
background-clip: text;
|
|
1787
|
-
background-size: 200% 100%;
|
|
1788
|
-
-webkit-text-fill-color: transparent;
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
1791
|
.tool-collapsed-icon {
|
|
1793
1792
|
display: flex;
|
|
1794
1793
|
width: 1rem;
|
|
@@ -1798,17 +1797,8 @@ mark {
|
|
|
1798
1797
|
justify-content: center;
|
|
1799
1798
|
}
|
|
1800
1799
|
|
|
1801
|
-
.tool-icon-dot {
|
|
1802
|
-
display: inline-block;
|
|
1803
|
-
width: 6px;
|
|
1804
|
-
height: 6px;
|
|
1805
|
-
border-radius: 50%;
|
|
1806
|
-
background-color: currentColor;
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
1800
|
.tool-collapsed-status {
|
|
1810
1801
|
flex: 1;
|
|
1811
|
-
font-weight: 500;
|
|
1812
1802
|
}
|
|
1813
1803
|
}
|
|
1814
1804
|
|
|
@@ -58,6 +58,16 @@ export const useCopyToClipboard = (text: string): [boolean, () => void] => {
|
|
|
58
58
|
return [copied, copy];
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Map a 0-1 claim score to a categorical label.
|
|
63
|
+
* Scores: 1.0 = supported, 0.4 = not_enough_info, 0.0 = contradicted.
|
|
64
|
+
*/
|
|
65
|
+
export function scoreToLabel(score: number): string {
|
|
66
|
+
if (score >= 0.8) return 'High';
|
|
67
|
+
if (score >= 0.1) return 'Low';
|
|
68
|
+
return 'Failed';
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export function convertToPercentage(
|
|
62
72
|
floatValue: number,
|
|
63
73
|
digits: number = 2,
|
package/src/halloumi/README.md
CHANGED
|
@@ -1 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
# Halloumi Fact-Checking Middleware
|
|
2
|
+
|
|
3
|
+
This middleware proxies fact-checking requests to the **rag-fact-checker** service (https://github.com/eea/rag-fact-check), which replaces the original Halloumi LLM-based integration.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Frontend (useQualityMarkers.js)
|
|
9
|
+
→ POST /_ha/generate
|
|
10
|
+
→ Express middleware (middleware.js)
|
|
11
|
+
→ POST {RAG_FACT_CHECKER_URL}/halloumi/generate
|
|
12
|
+
→ rag-fact-checker FastAPI service
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The rag-fact-checker's `/halloumi/generate` endpoint is a drop-in replacement for the original Halloumi API — it accepts the same request format (`{answer, sources}`) and returns a compatible response (`{claims, segments}`), so all existing frontend components (`HalloumiFeedback`, `useQualityMarkers`, `ClaimSegments`) work without changes.
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
| Environment Variable | Default | Description |
|
|
20
|
+
| ---------------------- | ----------------------- | ---------------------------------------- |
|
|
21
|
+
| `RAG_FACT_CHECKER_URL` | `http://localhost:8000` | Base URL of the rag-fact-checker service |
|
|
22
|
+
|
|
23
|
+
## Original Halloumi
|
|
24
|
+
|
|
25
|
+
The original implementation was based on Apache2 licensed https://github.com/oumi-ai/halloumi-demo/tree/d088e1f25e7785326a53bc120113e226ee2f54b7
|
|
26
|
+
|
|
27
|
+
It used a custom LLM model (HallOumi-8B) accessed via an LLM gateway, with complex prompt preprocessing, chunking, logprob-based postprocessing, and claim filtering. This has been replaced by the rag-fact-checker service which uses a claim extraction + per-claim verification pipeline.
|
|
@@ -1,41 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import debug from 'debug';
|
|
3
3
|
import { isPathAllowed } from '../middleware';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const log = debug('halloumi');
|
|
6
6
|
|
|
7
7
|
const MSG_INVALID_CONFIGURATION =
|
|
8
|
-
'Invalid configuration: missing
|
|
8
|
+
'Invalid configuration: missing RAG_FACT_CHECKER_URL';
|
|
9
9
|
|
|
10
10
|
// Allowed paths for _ha (Halloumi) proxy.
|
|
11
11
|
// When adding new endpoints, update this list.
|
|
12
|
-
const ALLOWED_HALLOUMI_PATHS = [
|
|
13
|
-
{ path: '/generate', methods: ['POST'] },
|
|
14
|
-
{ path: '/classify', methods: ['POST'] },
|
|
15
|
-
];
|
|
12
|
+
const ALLOWED_HALLOUMI_PATHS = [{ path: '/generate', methods: ['POST'] }];
|
|
16
13
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const generativeModel = {
|
|
21
|
-
name: 'Inhouse-LLM/HallOumi-8B',
|
|
22
|
-
apiUrl: `${LLMGW_URL}/chat/completions`,
|
|
23
|
-
plattScaling: {
|
|
24
|
-
a: -0.5764390035379638,
|
|
25
|
-
b: 0.16648741572432335,
|
|
26
|
-
},
|
|
27
|
-
isEmbeddingModel: false,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const classifyModel = {
|
|
31
|
-
name: 'Inhouse-LLM/HallOumi-8B-classifier',
|
|
32
|
-
apiUrl: `${LLMGW_URL}/v1/embeddings`,
|
|
33
|
-
isEmbeddingModel: true,
|
|
34
|
-
plattScaling: {
|
|
35
|
-
a: -0.9468640744087437,
|
|
36
|
-
b: -0.07379217647931409,
|
|
37
|
-
},
|
|
38
|
-
};
|
|
14
|
+
const RAG_FACT_CHECKER_URL =
|
|
15
|
+
process.env.RAG_FACT_CHECKER_URL || 'http://localhost:8000';
|
|
39
16
|
|
|
40
17
|
export default async function middleware(req, res, next) {
|
|
41
18
|
const path = req.url.replace('/_ha/', '/');
|
|
@@ -48,39 +25,52 @@ export default async function middleware(req, res, next) {
|
|
|
48
25
|
return;
|
|
49
26
|
}
|
|
50
27
|
|
|
51
|
-
if (!
|
|
28
|
+
if (!RAG_FACT_CHECKER_URL) {
|
|
52
29
|
res.send({
|
|
53
30
|
error: MSG_INVALID_CONFIGURATION,
|
|
54
31
|
});
|
|
55
32
|
return;
|
|
56
33
|
}
|
|
57
34
|
|
|
58
|
-
const model = {
|
|
59
|
-
...(path === '/classify' ? classifyModel : generativeModel),
|
|
60
|
-
apiKey: LLMGW_TOKEN,
|
|
61
|
-
};
|
|
62
35
|
const body = req.body;
|
|
63
|
-
|
|
64
|
-
// log('Halloumi body', body);
|
|
65
36
|
const { sources, answer } = body;
|
|
66
37
|
|
|
38
|
+
log('Halloumi request body', {
|
|
39
|
+
answer,
|
|
40
|
+
sources: sources?.length,
|
|
41
|
+
});
|
|
42
|
+
|
|
67
43
|
res.set('Content-Type', 'application/json');
|
|
68
44
|
|
|
69
45
|
try {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
46
|
+
const response = await fetch(`${RAG_FACT_CHECKER_URL}/halloumi/generate`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
answer,
|
|
53
|
+
sources,
|
|
54
|
+
max_context_segments: 0,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await response.json();
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
log('Halloumi error response', result);
|
|
62
|
+
res.status(response.status).send({
|
|
63
|
+
error: `Fact-checker error: ${result.detail || result.error || 'unknown error'}`,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log('Halloumi response', result);
|
|
69
|
+
res.send(result);
|
|
79
70
|
} catch (error) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
71
|
+
log('Halloumi fetch error', error);
|
|
72
|
+
res.status(502).send({
|
|
73
|
+
error: `Fact-checker unavailable: ${error.message || 'connection failed'}`,
|
|
83
74
|
});
|
|
84
|
-
// throw error;
|
|
85
75
|
}
|
|
86
76
|
}
|