@eeacms/volto-eea-chatbot 4.1.3 → 4.1.4

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,8 +1,12 @@
1
1
  import { Modal, ModalContent, ModalHeader } from 'semantic-ui-react';
2
2
  import cx from 'classnames';
3
- import { scoreToLabel } from '@eeacms/volto-eea-chatbot/ChatBlock/utils';
3
+ import {
4
+ claimBadgeLabel,
5
+ claimHeaderLabel,
6
+ claimScaleState,
7
+ } from '@eeacms/volto-eea-chatbot/ChatBlock/utils';
4
8
  import SVGIcon from '@eeacms/volto-eea-chatbot/ChatBlock/components/Icon';
5
- import { getSupportedBgColor } from './colors';
9
+ import { getVerdictBgColor } from './colors';
6
10
  import { ClaimSegments } from './ClaimSegments';
7
11
 
8
12
  import BotIcon from '@eeacms/volto-eea-chatbot/icons/bot.svg';
@@ -34,11 +38,9 @@ export function ClaimModal({ claim, markers, text, citedSources }) {
34
38
 
35
39
  return (
36
40
  <Modal
37
- className={cx('claim-modal', getSupportedBgColor(claim.score, 'claim'))}
41
+ className={cx('claim-modal', getVerdictBgColor(claim.score))}
38
42
  trigger={
39
- <span
40
- className={cx('claim', getSupportedBgColor(claim.score, 'claim'))}
41
- >
43
+ <span className={cx('claim', getVerdictBgColor(claim.score))}>
42
44
  {text}
43
45
  </span>
44
46
  }
@@ -49,7 +51,9 @@ export function ClaimModal({ claim, markers, text, citedSources }) {
49
51
  <div className="circle assistant">
50
52
  <SVGIcon name={BotIcon} size="20" color="white" />
51
53
  </div>
52
- <span className="claim-label">Verified Claim</span>
54
+ <span className="claim-label">
55
+ {claimHeaderLabel(claim.score, claim.context_limited)}
56
+ </span>
53
57
  </div>
54
58
  <blockquote className="claim-quote">
55
59
  &ldquo;
@@ -70,20 +74,30 @@ export function ClaimModal({ claim, markers, text, citedSources }) {
70
74
  <div className="score-badge-section">
71
75
  <div className="score-badge">
72
76
  <span className="score-percentage">
73
- {scoreToLabel(claim.score)}
77
+ {claimBadgeLabel(claim.score, claim.context_limited)}
74
78
  </span>
75
- <span className="score-label">Verification</span>
79
+ <span className="score-label">Fact check</span>
76
80
  </div>
77
- <div className="score-progress-bar">
78
- <div
79
- className="score-progress-fill"
80
- style={{ width: `${claim.score * 100}%` }}
81
- />
81
+ {/* Three discrete positions, not a fill. The per-claim score is a
82
+ verdict, not a probability: the old proportional bar made
83
+ not_enough_info look like "40% verified", which was never
84
+ measured. Decorative — the badge text carries the verdict. */}
85
+ <div
86
+ className="verdict-scale"
87
+ data-verdict={claimScaleState(claim.score, claim.context_limited)}
88
+ aria-hidden="true"
89
+ >
90
+ <span className="verdict-step" />
91
+ <span className="verdict-step" />
92
+ <span className="verdict-step" />
82
93
  </div>
83
94
  </div>
84
95
  <div className="rationale-section">
85
96
  <h5 className="rationale-header">Rationale</h5>
86
97
  <p className="claim-rationale">{claim.rationale}</p>
98
+ {/* No snippet caveat here: the header already says "Couldn't be
99
+ checked" and the badge says "Unverified". The explanation lives
100
+ once, at answer level, in HalloumiFeedback. */}
87
101
  </div>
88
102
  </div>
89
103
 
@@ -27,18 +27,50 @@ export function ClaimSegments({ segmentIds, segments, citedSources }) {
27
27
  .map((segment) => {
28
28
  const startOffset = Math.max(0, segment.startOffset); // sometimes startOffset comes as -1
29
29
  const endOffset = segment.endOffset;
30
- const text = joinedSources.slice(startOffset, endOffset);
31
- const source = citedSources.find(
32
- (source) =>
33
- startOffset >= source.startIndex &&
34
- endOffset <= source.halloumiContext.length + source.startIndex,
30
+
31
+ // A claim can cite several passages, possibly in different documents.
32
+ // Attribute by containment instead of requiring the segment to fit
33
+ // entirely inside one source — a segment that straddles a boundary
34
+ // would otherwise disappear from the modal without any warning.
35
+ const source =
36
+ citedSources.find(
37
+ (source) =>
38
+ startOffset >= source.startIndex &&
39
+ startOffset < source.startIndex + source.halloumiContext.length,
40
+ ) ||
41
+ citedSources.find(
42
+ (source) =>
43
+ endOffset > source.startIndex &&
44
+ endOffset <= source.startIndex + source.halloumiContext.length,
45
+ );
46
+
47
+ if (!source) {
48
+ // eslint-disable-next-line no-console
49
+ console.warn(
50
+ `Could not attribute segment ${segment.id} to a source`,
51
+ { startOffset, endOffset },
52
+ citedSources,
53
+ );
54
+ return null;
55
+ }
56
+
57
+ // Clamp to the attributed source so the snippet never renders text that
58
+ // belongs to the neighbouring document.
59
+ const clippedStart = Math.max(startOffset, source.startIndex);
60
+ const clippedEnd = Math.min(
61
+ endOffset,
62
+ source.startIndex + source.halloumiContext.length,
35
63
  );
64
+
36
65
  return {
37
66
  ...segment,
38
- text,
39
- source_id: source?.id,
67
+ startOffset: clippedStart,
68
+ endOffset: clippedEnd,
69
+ text: joinedSources.slice(clippedStart, clippedEnd),
70
+ source_id: source.id,
40
71
  };
41
- });
72
+ })
73
+ .filter((snippet) => !!snippet && snippet.endOffset > snippet.startOffset);
42
74
 
43
75
  const sourcesWithSnippets = citedSources
44
76
  .map((source) => ({
@@ -1,5 +1,19 @@
1
1
  import React from 'react';
2
2
 
3
+ /**
4
+ * Render a source document with the evidence segments of a claim highlighted.
5
+ *
6
+ * Segment offsets are half-open ranges `[startOffset, endOffset)` expressed in
7
+ * the coordinates of the *joined* sources string, so they are shifted by
8
+ * `sourceStartIndex` before slicing this source's text.
9
+ *
10
+ * A claim can be supported by several evidence quotes, and those quotes can
11
+ * overlap (two passages covering the same sentence, for example). Because the
12
+ * renderer splits the text on segment boundaries, overlapping segments would
13
+ * emit the same characters twice — segments are therefore clipped to what is
14
+ * still uncovered, and a fully covered segment keeps its id on the segment that
15
+ * covers it so its "Jump to citation" chip still scrolls somewhere sensible.
16
+ */
3
17
  export const RenderClaimView = (props) => {
4
18
  const {
5
19
  value,
@@ -10,16 +24,36 @@ export const RenderClaimView = (props) => {
10
24
  sourceStartIndex = 0,
11
25
  } = props;
12
26
 
13
- const sortedSegments = [...segments].sort(
14
- (a, b) => a.startOffset - b.startOffset,
15
- );
27
+ const sortedSegments = [...segments]
28
+ .filter(
29
+ (segment) =>
30
+ Number.isFinite(segment.startOffset) &&
31
+ Number.isFinite(segment.endOffset),
32
+ )
33
+ .sort((a, b) => a.startOffset - b.startOffset);
16
34
 
17
35
  const parts = [];
18
36
  let lastIndex = 0;
37
+ let lastSegmentPart = null;
19
38
 
20
39
  sortedSegments.forEach((segment) => {
21
- const segmentStart = segment.startOffset - sourceStartIndex;
22
- const segmentEnd = segment.endOffset - sourceStartIndex + 1;
40
+ const segmentStart = Math.max(
41
+ 0,
42
+ segment.startOffset - sourceStartIndex,
43
+ lastIndex,
44
+ );
45
+ const segmentEnd = Math.min(
46
+ value.length,
47
+ segment.endOffset - sourceStartIndex,
48
+ );
49
+
50
+ if (segmentEnd <= segmentStart) {
51
+ // Nothing left of this segment — it is covered by the previous one.
52
+ if (lastSegmentPart) {
53
+ lastSegmentPart.ids.push(segment.id);
54
+ }
55
+ return;
56
+ }
23
57
 
24
58
  // Add the text part before the current segment
25
59
  if (segmentStart > lastIndex) {
@@ -30,12 +64,13 @@ export const RenderClaimView = (props) => {
30
64
  }
31
65
 
32
66
  // Add the segment part
33
- parts.push({
67
+ const part = {
34
68
  type: 'segment',
35
- ...segment,
69
+ ids: [segment.id],
36
70
  content: value.slice(segmentStart, segmentEnd),
37
- });
38
-
71
+ };
72
+ parts.push(part);
73
+ lastSegmentPart = part;
39
74
  lastIndex = segmentEnd;
40
75
  });
41
76
 
@@ -55,12 +90,16 @@ export const RenderClaimView = (props) => {
55
90
  const content = part.content.split('\n');
56
91
 
57
92
  if (part.type === 'segment') {
58
- const isSelectedSegment = part.id === visibleSegmentId;
93
+ const isSelectedSegment = part.ids.includes(visibleSegmentId);
59
94
  return (
60
- <React.Fragment key={part.id || index}>
95
+ <React.Fragment key={part.ids.join('-') || index}>
61
96
  <span
62
97
  ref={(el) => {
63
- if (el) spanRefs.current[part.id] = el;
98
+ if (el) {
99
+ part.ids.forEach((id) => {
100
+ spanRefs.current[id] = el;
101
+ });
102
+ }
64
103
  }}
65
104
  className={`citation-segment ${
66
105
  isSelectedSegment ? 'active' : ''
@@ -68,7 +107,11 @@ export const RenderClaimView = (props) => {
68
107
  >
69
108
  <mark className="citation-highlight">
70
109
  {part.content.trim()}
71
- <sup className="citation-ref">{part.id}</sup>
110
+ {part.ids.map((id) => (
111
+ <sup key={id} className="citation-ref">
112
+ {id}
113
+ </sup>
114
+ ))}
72
115
  </mark>
73
116
  {!endline && <>&nbsp;</>}
74
117
  </span>
@@ -7,6 +7,28 @@ export function getSupportedTextColor(score) {
7
7
  return 'text-gray-500';
8
8
  }
9
9
 
10
+ /**
11
+ * Colour for a single verified claim.
12
+ *
13
+ * A per-claim score is a verdict (supported 1.0, not_enough_info 0.4,
14
+ * contradicted 0.0), not a measurement on a 0-1 scale, so the eight-step gradient
15
+ * in `getSupportedBgColor` implied a confidence level that was never computed -
16
+ * and it put `not_enough_info` in the red family, which reads as "the sources
17
+ * contradict this" when they actually said nothing. Three bands on one green ladder
18
+ * plus red: dark green for support, pale green for sources that said nothing, red for
19
+ * a conflict. The two greens are far apart in lightness on purpose - adjacent greens
20
+ * read as one colour.
21
+ *
22
+ * The class names must exist in `colors.less`: the `claim-*` utilities come from an
23
+ * explicit list of `.generate-colors()` calls, and an unlisted class renders unstyled,
24
+ * silently. `colors.test.js` asserts the two stay in step.
25
+ */
26
+ export function getVerdictBgColor(score, prefix = 'claim') {
27
+ if (score >= 0.8) return `${prefix}-green-700`;
28
+ if (score >= 0.1) return `${prefix}-green-300`;
29
+ return `${prefix}-red-500`;
30
+ }
31
+
10
32
  export function getSupportedBgColor(score, prefix = 'bg') {
11
33
  if (0 <= score && score < 0.125) {
12
34
  return `${prefix}-red-500`;
@@ -48,5 +48,14 @@
48
48
  .generate-colors(claim, red-200, @red-200, 8, 30);
49
49
  .generate-colors(claim, green-500, @green-500, 45, 20);
50
50
  .generate-colors(claim, green-400, @green-400, 35, 25);
51
- .generate-colors(claim, green-300, @green-300, 20, 35);
51
+ .generate-colors(claim, green-300, @green-300, 20, 48);
52
52
  .generate-colors(claim, green-200, @green-200, 10, 45);
53
+
54
+ // The three claim verdicts are a green ladder plus red: green-700 = supported, the
55
+ // darkest, green-300 = not confirmed by the sources, red-500 = contradicted. Kept
56
+ // deliberately far apart in lightness - two adjacent greens read as one colour and the
57
+ // distinction is lost. The darken value on green-300 is high (48) because it is the
58
+ // chip's text colour on a pale background: at 35 the pair was 2.7:1, unreadable.
59
+ // `getVerdictBgColor` emits these class names, and a test asserts every emitted class
60
+ // is generated here.
61
+ .generate-colors(claim, green-700, @green-700, 32, 8);
@@ -1,4 +1,49 @@
1
- import { getSupportedTextColor, getSupportedBgColor } from './colors';
1
+ import {
2
+ getSupportedTextColor,
3
+ getSupportedBgColor,
4
+ getVerdictBgColor,
5
+ } from './colors';
6
+
7
+ describe('getVerdictBgColor', () => {
8
+ it('gives each verdict one colour band', () => {
9
+ expect(getVerdictBgColor(1.0)).toBe('claim-green-700');
10
+ expect(getVerdictBgColor(0.8)).toBe('claim-green-700');
11
+ expect(getVerdictBgColor(0.4)).toBe('claim-green-300');
12
+ expect(getVerdictBgColor(0.1)).toBe('claim-green-300');
13
+ expect(getVerdictBgColor(0.0)).toBe('claim-red-500');
14
+ });
15
+
16
+ it('keeps not_enough_info out of the red family', () => {
17
+ // red reads as "the sources contradict this"; they said nothing
18
+ expect(getVerdictBgColor(0.4)).not.toContain('red');
19
+ });
20
+
21
+ it('separates the two greens by more than one step', () => {
22
+ // adjacent greens read as one colour, which would erase the distinction
23
+ const step = (cls) => Number(cls.match(/green-(\d+)/)[1]);
24
+ expect(
25
+ step(getVerdictBgColor(1.0)) - step(getVerdictBgColor(0.4)),
26
+ ).toBeGreaterThanOrEqual(300);
27
+ });
28
+
29
+ it('only emits classes that colors.less actually generates', () => {
30
+ // an unlisted .generate-colors() call means the class renders unstyled, which
31
+ // fails silently - no colour, no error
32
+ const less = require('fs').readFileSync(
33
+ require('path').join(__dirname, 'colors.less'),
34
+ 'utf-8',
35
+ );
36
+ for (const cls of [
37
+ getVerdictBgColor(1.0),
38
+ getVerdictBgColor(0.4),
39
+ getVerdictBgColor(0.0),
40
+ ]) {
41
+ expect(less).toContain(
42
+ `.generate-colors(claim, ${cls.replace('claim-', '')},`,
43
+ );
44
+ }
45
+ });
46
+ });
2
47
 
3
48
  describe('getSupportedTextColor', () => {
4
49
  it('should return "text-red-500" for scores between 0 and 0.5', () => {
@@ -5,3 +5,4 @@ export { useMarked } from './useMarked';
5
5
  export { useQualityMarkers } from './useQualityMarkers';
6
6
  export { useDeepCompareMemoize } from './useDeepCompareMemoize';
7
7
  export { useScrollonStream } from './useScrollonStream';
8
+ export { useChunkEvidence } from './useChunkEvidence';
@@ -0,0 +1,80 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+
3
+ import {
4
+ CHUNK_EVIDENCE_WINDOW,
5
+ fetchEvidence,
6
+ } from '@eeacms/volto-eea-chatbot/ChatBlock/services/chunkEvidence';
7
+
8
+ /**
9
+ * Resolve real Onyx chunk text for the documents about to be sent to the
10
+ * fact-checker.
11
+ *
12
+ * Only runs when `enabled` (i.e. quality control is being requested), and
13
+ * re-runs only when the set of `document_id` + `chunk_ind` values changes.
14
+ *
15
+ * `evidenceSettled` is what callers must gate the fact-check call on. It is
16
+ * false for a key that has not been resolved yet, so the check cannot fire with
17
+ * blurbs in the same commit that started the evidence fetch. It also becomes
18
+ * true when a fetch fails — falling back to blurbs is better than never
19
+ * verifying at all.
20
+ */
21
+ export function useChunkEvidence(
22
+ docs: any[],
23
+ {
24
+ enabled = true,
25
+ window = CHUNK_EVIDENCE_WINDOW,
26
+ }: { enabled?: boolean; window?: number } = {},
27
+ ) {
28
+ const [evidence, setEvidence] = useState<Record<string, any>>({});
29
+ const [isFetchingEvidence, setIsFetchingEvidence] = useState(false);
30
+ const [settledKey, setSettledKey] = useState('');
31
+
32
+ const docsRef = useRef(docs);
33
+ docsRef.current = docs;
34
+
35
+ const key = useMemo(
36
+ () =>
37
+ (docs || [])
38
+ .map((doc: any) => `${doc?.document_id}@@${doc?.chunk_ind ?? 0}`)
39
+ .filter((part: string) => !part.startsWith('undefined'))
40
+ .join('|'),
41
+ [docs],
42
+ );
43
+
44
+ useEffect(() => {
45
+ if (!enabled || !key) {
46
+ setIsFetchingEvidence(false);
47
+ return;
48
+ }
49
+
50
+ let cancelled = false;
51
+ setIsFetchingEvidence(true);
52
+
53
+ fetchEvidence(docsRef.current, { window })
54
+ .then((result) => {
55
+ if (cancelled) {
56
+ return;
57
+ }
58
+ setEvidence(result);
59
+ setIsFetchingEvidence(false);
60
+ setSettledKey(key);
61
+ })
62
+ .catch(() => {
63
+ // Fall back to whatever the stream gave us (blurbs).
64
+ if (!cancelled) {
65
+ setIsFetchingEvidence(false);
66
+ setSettledKey(key);
67
+ }
68
+ });
69
+
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, [key, enabled, window]);
74
+
75
+ return {
76
+ evidence,
77
+ isFetchingEvidence,
78
+ evidenceSettled: key === '' || settledKey === key,
79
+ };
80
+ }
@@ -95,6 +95,7 @@ export function ChatBlockSchema({ assistants, data }) {
95
95
  'batchSize',
96
96
  'noSupportDocumentsMessage',
97
97
  'qualityCheckContext',
98
+ 'chunkEvidenceWindow',
98
99
  'qualityCheckStages',
99
100
  ]
100
101
  : []),
@@ -214,6 +215,17 @@ export function ChatBlockSchema({ assistants, data }) {
214
215
  ['all', 'All documents passed to LLM'],
215
216
  ],
216
217
  },
218
+ chunkEvidenceWindow: {
219
+ title: 'Evidence window (chunks around each source)',
220
+ type: 'number',
221
+ default: 2,
222
+ description:
223
+ 'How many Onyx chunks to fetch on each side of the matched chunk before ' +
224
+ 'fact-checking. 0 = centre chunk only (usually not enough), 2 = what Onyx ' +
225
+ 'feeds the answer generator by default, 5 = Onyx at its widest. Each extra ' +
226
+ 'step costs two more chunk requests per document, and values above 20 are ' +
227
+ 'clamped.',
228
+ },
217
229
  qualityCheckStages: {
218
230
  title: 'Score ranges',
219
231
  widget: 'object_list',