@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.
@@ -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: isLastItem,
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
- // Expanded view (full content) - used for completed tools or when overall complete
206
+ // Detailed view for streaming reasoning/search tools and completed tools
203
207
  return (
204
- <div className="tool-item-expanded">
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
- // If we have final_documents and no citations yet, create a fallback mapping
213
- // This ensures the Sources tab shows up in v3 when citation_info is missing
214
- if (
215
- packet.obj.type === PacketType.MESSAGE_START &&
216
- data.final_documents &&
217
- this._citations.size === 0
218
- ) {
219
- data.final_documents.forEach((doc: OnyxDocument, index: number) => {
220
- if (doc.document_id) {
221
- this._citations.set(index + 1, doc.document_id);
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?.forEach((citation: StreamingCitation) => {
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
  }
@@ -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,
@@ -1 +1,27 @@
1
- code based on Apache2 licensed https://github.com/oumi-ai/halloumi-demo/tree/d088e1f25e7785326a53bc120113e226ee2f54b7
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
- // import debug from 'debug';
2
- import { getVerifyClaimResponse } from './generative';
1
+ import fetch from 'node-fetch';
2
+ import debug from 'debug';
3
3
  import { isPathAllowed } from '../middleware';
4
4
 
5
- // const log = debug('halloumi');
5
+ const log = debug('halloumi');
6
6
 
7
7
  const MSG_INVALID_CONFIGURATION =
8
- 'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL';
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 LLMGW_URL = process.env.LLMGW_URL;
18
- const LLMGW_TOKEN = process.env.LLMGW_TOKEN;
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 (!(LLMGW_TOKEN && LLMGW_URL)) {
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 resp = await getVerifyClaimResponse(
71
- model,
72
- // TODO: map with citation id
73
- sources,
74
- answer,
75
- { ip: req.headers['x-forwarded-for'] || req.ip },
76
- );
77
- // log('Halloumi response', resp);
78
- res.send(resp);
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
- res.status(500).send({
81
- error: `Halloumi error: ${error}`,
82
- traceback: error.stack || null,
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
  }