@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +53 -4
  2. package/README.md +115 -12
  3. package/package.json +1 -1
  4. package/src/ChatBlock/ChatBlockView.jsx +7 -1
  5. package/src/ChatBlock/chat/AIMessage.tsx +79 -45
  6. package/src/ChatBlock/chat/ChatMessage.tsx +17 -10
  7. package/src/ChatBlock/chat/ChatWindow.tsx +11 -0
  8. package/src/ChatBlock/chat/MessageContext.ts +12 -0
  9. package/src/ChatBlock/chat/index.ts +1 -0
  10. package/src/ChatBlock/components/markdown/ClaimModal.jsx +3 -3
  11. package/src/ChatBlock/components/markdown/index.jsx +1 -0
  12. package/src/ChatBlock/hooks/useQualityMarkers.js +21 -4
  13. package/src/ChatBlock/index.js +9 -1
  14. package/src/ChatBlock/packets/RendererComponent.tsx +9 -0
  15. package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +15 -3
  16. package/src/ChatBlock/schema.jsx +13 -0
  17. package/src/ChatBlock/services/messageProcessor.ts +9 -12
  18. package/src/ChatBlock/types/interfaces.ts +25 -0
  19. package/src/ChatBlock/utils/index.tsx +10 -0
  20. package/src/ChatBlock/variations.ts +44 -0
  21. package/src/halloumi/README.md +27 -1
  22. package/src/halloumi/middleware.js +41 -50
  23. package/src/halloumi/middleware.test.js +102 -103
  24. package/src/halloumi/filtering.js +0 -149
  25. package/src/halloumi/filtering.test.js +0 -242
  26. package/src/halloumi/generative.js +0 -324
  27. package/src/halloumi/generative.test.js +0 -386
  28. package/src/halloumi/markdown-splitter.js +0 -172
  29. package/src/halloumi/markdown-splitter.test.js +0 -133
  30. package/src/halloumi/postprocessing.js +0 -247
  31. package/src/halloumi/postprocessing.test.js +0 -441
  32. package/src/halloumi/preprocessing.js +0 -117
  33. package/src/halloumi/preprocessing.test.js +0 -184
@@ -5,13 +5,18 @@ const Sentry = loadable.lib(
5
5
  () => import(/* webpackChunkName: "s_entry-browser" */ '@sentry/browser'), // chunk name avoids ad blockers
6
6
  );
7
7
 
8
- async function fetchHalloumi(answer, sources, maxContextSegments) {
8
+ async function fetchHalloumi(answer, sources, maxContextSegments, batchSize) {
9
9
  const halloumiResponse = await fetch('/_ha/generate', {
10
10
  method: 'POST',
11
11
  headers: {
12
12
  'Content-Type': 'application/json',
13
13
  },
14
- body: JSON.stringify({ answer, sources, maxContextSegments }),
14
+ body: JSON.stringify({
15
+ answer,
16
+ sources,
17
+ maxContextSegments,
18
+ ...(batchSize != null && { batch_size: batchSize }),
19
+ }),
15
20
  });
16
21
  return halloumiResponse;
17
22
  }
@@ -38,6 +43,7 @@ export function useQualityMarkers(
38
43
  message,
39
44
  sources,
40
45
  maxContextSegments = 0,
46
+ batchSize,
41
47
  ) {
42
48
  const [halloumiResponse, setHalloumiResponse] = React.useState(null);
43
49
  const [isLoading, setIsLoading] = React.useState(false);
@@ -48,7 +54,16 @@ export function useQualityMarkers(
48
54
 
49
55
  React.useEffect(() => {
50
56
  async function handler() {
51
- const textSources = sources.map(({ halloumiContext }) => halloumiContext);
57
+ // Prefer structured sources (with title metadata) over plain text.
58
+ // Fall back to halloumiContext strings for backward compatibility.
59
+ const firstSource = sources[0];
60
+ const hasStructuredSources =
61
+ firstSource && typeof firstSource.halloumiSource === 'object';
62
+
63
+ const halloumiSources = hasStructuredSources
64
+ ? sources.map(({ halloumiSource }) => halloumiSource)
65
+ : sources.map(({ halloumiContext }) => halloumiContext);
66
+
52
67
  if (sources.length === 0) {
53
68
  setHalloumiResponse(empty(message, FAILURE_RATIONALE));
54
69
  return;
@@ -59,8 +74,9 @@ export function useQualityMarkers(
59
74
  try {
60
75
  const feedback = await fetchHalloumi(
61
76
  message,
62
- textSources,
77
+ halloumiSources,
63
78
  maxContextSegments,
79
+ batchSize,
64
80
  );
65
81
  const body = await feedback.json();
66
82
  // console.log({ message, sources, body });
@@ -88,6 +104,7 @@ export function useQualityMarkers(
88
104
  message,
89
105
  sources,
90
106
  maxContextSegments,
107
+ batchSize,
91
108
  ]);
92
109
 
93
110
  if (halloumiResponse !== null) {
@@ -1,6 +1,7 @@
1
1
  import codeSVG from '@plone/volto/icons/code.svg';
2
2
  import ChatBlockView from './ChatBlockView';
3
3
  import ChatBlockEdit from './ChatBlockEdit';
4
+ import ChatWindow from './chat/ChatWindow';
4
5
  import { ChatBlockSchema } from './schema';
5
6
 
6
7
  export default function installChatBlock(config) {
@@ -26,7 +27,14 @@ export default function installChatBlock(config) {
26
27
  addPermission: [],
27
28
  view: [],
28
29
  },
29
- variations: [],
30
+ variations: [
31
+ {
32
+ id: 'classic',
33
+ title: 'Classic',
34
+ isDefault: true,
35
+ view: ChatWindow,
36
+ },
37
+ ],
30
38
  };
31
39
 
32
40
  if (!config.blocks.blocksConfig.danswerChat) {
@@ -87,6 +87,9 @@ export function RendererComponent({
87
87
  markers,
88
88
  stableContextSources,
89
89
  addQualityMarkersPlugin,
90
+ extraMarkdownComponents,
91
+ extraRemarkPlugins,
92
+ extraRehypePlugins,
90
93
  }: {
91
94
  packets: Packet[];
92
95
  onComplete: () => void;
@@ -98,6 +101,9 @@ export function RendererComponent({
98
101
  markers?: any;
99
102
  stableContextSources?: any;
100
103
  addQualityMarkersPlugin?: any;
104
+ extraMarkdownComponents?: Record<string, React.ComponentType<any>>;
105
+ extraRemarkPlugins?: any[];
106
+ extraRehypePlugins?: any[];
101
107
  }) {
102
108
  const RendererFn = findRenderer({ packets });
103
109
 
@@ -116,6 +122,9 @@ export function RendererComponent({
116
122
  markers={markers}
117
123
  stableContextSources={stableContextSources}
118
124
  addQualityMarkersPlugin={addQualityMarkersPlugin}
125
+ extraMarkdownComponents={extraMarkdownComponents}
126
+ extraRemarkPlugins={extraRemarkPlugins}
127
+ extraRehypePlugins={extraRehypePlugins}
119
128
  >
120
129
  {children}
121
130
  </RendererFn>
@@ -30,6 +30,9 @@ export const MessageTextRenderer: MessageRenderer<ChatPacket> = ({
30
30
  markers,
31
31
  stableContextSources,
32
32
  addQualityMarkersPlugin,
33
+ extraMarkdownComponents,
34
+ extraRemarkPlugins = [],
35
+ extraRehypePlugins = [],
33
36
  }) => {
34
37
  const { remarkGfm } = libs;
35
38
 
@@ -163,9 +166,18 @@ export const MessageTextRenderer: MessageRenderer<ChatPacket> = ({
163
166
  const renderedContent = (
164
167
  <div className="message-text-content">
165
168
  <Markdown
166
- components={components(message, markers, stableContextSources)}
167
- remarkPlugins={remarkGfm ? [remarkGfm.default] : []}
168
- rehypePlugins={addQualityMarkersPlugin ? [addQualityMarkersPlugin] : []}
169
+ components={{
170
+ ...components(message, markers, stableContextSources),
171
+ ...extraMarkdownComponents,
172
+ }}
173
+ remarkPlugins={[
174
+ ...(remarkGfm ? [remarkGfm.default] : []),
175
+ ...extraRemarkPlugins,
176
+ ]}
177
+ rehypePlugins={[
178
+ ...(addQualityMarkersPlugin ? [addQualityMarkersPlugin] : []),
179
+ ...extraRehypePlugins,
180
+ ]}
169
181
  >
170
182
  {displayContent}
171
183
  </Markdown>
@@ -92,6 +92,7 @@ export function ChatBlockSchema({ assistants, data }) {
92
92
  ...(data.qualityCheck && data.qualityCheck !== 'disabled'
93
93
  ? [
94
94
  'maxContextSegments',
95
+ 'batchSize',
95
96
  'noSupportDocumentsMessage',
96
97
  'qualityCheckContext',
97
98
  'qualityCheckStages',
@@ -109,6 +110,11 @@ export function ChatBlockSchema({ assistants, data }) {
109
110
  },
110
111
  ],
111
112
  properties: {
113
+ // NOTE: the `variation` field is NOT declared here on purpose. Volto
114
+ // core injects it automatically in the block sidebar
115
+ // (withVariationSchemaEnhancer, wired into BlockDataForm) from
116
+ // blocksConfig.eeaChatbot.variations whenever 2+ variations are
117
+ // registered. See @plone/volto/helpers/Extensions.
112
118
  enableShowTotalFailMessage: {
113
119
  title: 'Show total failure message',
114
120
  type: 'boolean',
@@ -252,6 +258,13 @@ range is from 0 to 100`,
252
258
  description:
253
259
  'Optimize quality check performance by grouping sentences into a max number of segments. Set to 0 to disable grouping and instead use one sentence per segment.',
254
260
  },
261
+ batchSize: {
262
+ title: 'Batch size',
263
+ type: 'number',
264
+ default: 20,
265
+ description:
266
+ 'Number of claims to verify per LLM call. Higher values mean fewer LLM calls but larger prompts. Set to 1 for sequential per-claim verification.',
267
+ },
255
268
  feedbackReasons: {
256
269
  title: 'Feedback reasons',
257
270
  description: 'Select the reasons for negative feedback.',
@@ -211,8 +211,9 @@ export class MessageProcessor {
211
211
  this._documents = Array.from(this.documentMap.values());
212
212
  }
213
213
 
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
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.
216
217
  if (
217
218
  packet.obj.type === PacketType.MESSAGE_START &&
218
219
  data.final_documents &&
@@ -240,9 +241,12 @@ export class MessageProcessor {
240
241
  */
241
242
  private processCitations(packet: Packet) {
242
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
+
243
248
  const citationInfo = packet.obj as any;
244
249
  if (citationInfo.citation_number && citationInfo.document_id) {
245
- this.clearFallbackCitations();
246
250
  this._citations.set(
247
251
  citationInfo.citation_number,
248
252
  citationInfo.document_id,
@@ -254,15 +258,8 @@ export class MessageProcessor {
254
258
  return;
255
259
  }
256
260
  const citationDelta = packet.obj as CitationDelta;
257
- const explicitCitations = (citationDelta.citations || []).filter(
258
- (citation: StreamingCitation) =>
259
- citation.citation_num != null && Boolean(citation.document_id),
260
- );
261
- if (explicitCitations.length > 0) {
262
- this.clearFallbackCitations();
263
- }
264
- explicitCitations.forEach((citation: StreamingCitation) => {
265
- if (!this._citations.has(citation.citation_num)) {
261
+ citationDelta.citations?.forEach((citation: StreamingCitation) => {
262
+ if (citation.document_id && !this._citations.has(citation.citation_num)) {
266
263
  this._citations.set(citation.citation_num, citation.document_id);
267
264
  }
268
265
  });
@@ -82,6 +82,17 @@ export interface MessageRendererProps<T extends Packet = Packet> {
82
82
  markers?: any;
83
83
  stableContextSources?: any;
84
84
  addQualityMarkersPlugin?: any;
85
+ /**
86
+ * Extra react-markdown component overrides (e.g. for custom elements
87
+ * produced by remark/rehype plugins), merged on top of the built-in
88
+ * mapping. Allows presentations to plug custom components inline in the
89
+ * message text.
90
+ */
91
+ extraMarkdownComponents?: Record<string, React.ComponentType<any>>;
92
+ /** Extra remark plugins, appended after the built-in ones. */
93
+ extraRemarkPlugins?: any[];
94
+ /** Extra rehype plugins, appended after the built-in ones. */
95
+ extraRehypePlugins?: any[];
85
96
  onComplete: () => void;
86
97
  animate: boolean;
87
98
  stopPacketSeen: boolean;
@@ -141,11 +152,25 @@ export interface ChatMessageProps {
141
152
  enableMatomoTracking?: boolean;
142
153
  persona?: number;
143
154
  maxContextSegments?: number;
155
+ batchSize?: number;
144
156
  isLastMessage?: boolean;
145
157
  className?: string;
146
158
  chatWindowRef?: React.RefObject<HTMLDivElement>;
147
159
  chatWindowEndRef?: React.RefObject<HTMLDivElement>;
148
160
  showTools?: PacketType[];
161
+ /**
162
+ * Hide the classic "Sources" tab, sidebar and inline source list for this
163
+ * presentation (the answer text still renders, and any injected markdown
164
+ * components still work). Used by variations that present the cited
165
+ * documents differently (e.g. inline catalogue cards).
166
+ */
167
+ hideSourcesTab?: boolean;
168
+ /** Extra remark plugins merged after the built-in ones. */
169
+ extraRemarkPlugins?: any[];
170
+ /** Extra react-markdown components merged after the built-in ones. */
171
+ extraMarkdownComponents?: any;
172
+ /** Extra rehype plugins merged after the built-in ones. */
173
+ extraRehypePlugins?: any[];
149
174
  }
150
175
 
151
176
  export interface Persona {
@@ -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,
@@ -0,0 +1,44 @@
1
+ import type { ComponentType } from 'react';
2
+ import config from '@plone/volto/registry';
3
+
4
+ /**
5
+ * A chatbot block presentation variation.
6
+ *
7
+ * The `view` component is a complete presentation of the chatbot block.
8
+ * It receives the same props the classic presentation (ChatWindow) receives
9
+ * today: `{ persona, data, block_id, isEditMode, isPlaywrightTest,
10
+ * initialQuery, initialDeepResearch, ... }` plus whatever the HOCs inject
11
+ * (e.g. `rehypePrism`, `remarkGfm` when the view wraps itself with
12
+ * `injectLazyLibs`).
13
+ */
14
+ export interface ChatBlockVariation {
15
+ id: string;
16
+ title: string;
17
+ isDefault?: boolean;
18
+ view: ComponentType<any>;
19
+ edit?: ComponentType<any>;
20
+ schemaEnhancer?: (args: { schema: any; formData: any }) => any;
21
+ }
22
+
23
+ /**
24
+ * Resolve the active variation from the block data.
25
+ *
26
+ * Semantics follow the established `variations` pattern of the EEA Volto
27
+ * addons (volto-tabs-block, volto-block-data-table, listing block):
28
+ * explicit `data.variation` → `isDefault` → first registered → undefined.
29
+ * Old block content without a `variation` field resolves to the default.
30
+ */
31
+ export function getChatVariation(
32
+ data: Record<string, any>,
33
+ ): ChatBlockVariation | undefined {
34
+ const variations: ChatBlockVariation[] =
35
+ config.blocks?.blocksConfig?.eeaChatbot?.variations ?? [];
36
+ if (variations.length === 0) {
37
+ return undefined;
38
+ }
39
+ return (
40
+ variations.find((v) => v.id === data?.variation) ??
41
+ variations.find((v) => v.isDefault) ??
42
+ variations[0]
43
+ );
44
+ }
@@ -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; //betterleaks:allow
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,53 @@ 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, //betterleaks:allow
61
- };
62
35
  const body = req.body;
36
+ const { sources, answer, batch_size } = body;
63
37
 
64
- // log('Halloumi body', body);
65
- const { sources, answer } = body;
38
+ log('Halloumi request body', {
39
+ answer,
40
+ sources: sources?.length,
41
+ });
66
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
+ ...(batch_size != null && { batch_size }),
56
+ }),
57
+ });
58
+
59
+ const result = await response.json();
60
+
61
+ if (!response.ok) {
62
+ log('Halloumi error response', result);
63
+ res.status(response.status).send({
64
+ error: `Fact-checker error: ${result.detail || result.error || 'unknown error'}`,
65
+ });
66
+ return;
67
+ }
68
+
69
+ log('Halloumi response', result);
70
+ res.send(result);
79
71
  } catch (error) {
80
- res.status(500).send({
81
- error: `Halloumi error: ${error}`,
82
- traceback: error.stack || null,
72
+ log('Halloumi fetch error', error);
73
+ res.status(502).send({
74
+ error: `Fact-checker unavailable: ${error.message || 'connection failed'}`,
83
75
  });
84
- // throw error;
85
76
  }
86
77
  }