@eeacms/volto-eea-chatbot 4.1.3 → 4.1.5
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/AGENTS.md +4 -0
- package/CHANGELOG.md +25 -3
- package/README.md +1 -14
- package/artifacts/I18N-SPEC.md +257 -0
- package/package.json +1 -1
- package/src/AISearchInput/index.js +20 -6
- package/src/ChatBlock/chat/AIMessage.tsx +71 -21
- package/src/ChatBlock/chat/ChatWindow.tsx +3 -0
- package/src/ChatBlock/components/HalloumiFeedback.jsx +25 -2
- package/src/ChatBlock/components/markdown/ClaimModal.jsx +34 -12
- package/src/ChatBlock/components/markdown/ClaimSegments.jsx +40 -8
- package/src/ChatBlock/components/markdown/RenderClaimView.jsx +56 -13
- package/src/ChatBlock/components/markdown/colors.js +31 -28
- package/src/ChatBlock/components/markdown/colors.less +27 -9
- package/src/ChatBlock/components/markdown/colors.test.js +86 -56
- package/src/ChatBlock/hooks/index.ts +1 -0
- package/src/ChatBlock/hooks/useChunkEvidence.ts +80 -0
- package/src/ChatBlock/schema.jsx +12 -0
- package/src/ChatBlock/services/chunkEvidence.ts +251 -0
- package/src/ChatBlock/style.less +65 -11
- package/src/ChatBlock/types/interfaces.ts +6 -0
- package/src/ChatBlock/utils/index.tsx +56 -6
- package/src/halloumi/README.md +91 -0
- package/src/index.js +1 -0
- package/src/middleware.js +9 -0
- package/src/middleware.test.js +56 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the real document text behind a chat answer.
|
|
3
|
+
*
|
|
4
|
+
* The Onyx v3 stream ships documents as search *blurbs* (~600 chars) — it has no
|
|
5
|
+
* `content` field at all (see `SearchDoc` in Onyx `context/search/models.py`).
|
|
6
|
+
* Sending blurbs to the fact-checker makes supported claims look hallucinated,
|
|
7
|
+
* because the answer was written from full chunk text, not from those snippets.
|
|
8
|
+
*
|
|
9
|
+
* Onyx already exposes that text:
|
|
10
|
+
*
|
|
11
|
+
* GET /api/document/chunk-info?document_id=<id>&chunk_id=<n>
|
|
12
|
+
* -> { "content": "<chunk text>", "num_tokens": 512 }
|
|
13
|
+
*
|
|
14
|
+
* and the stream already carries both parameters for every document
|
|
15
|
+
* (`document_id` + `chunk_ind`). This module goes through the Volto `/_da`
|
|
16
|
+
* proxy so no extra credentials or CORS setup are needed.
|
|
17
|
+
*
|
|
18
|
+
* Notes:
|
|
19
|
+
* - One chunk is ~512 tokens (`DOC_EMBEDDING_CONTEXT_SIZE`). Onyx builds the
|
|
20
|
+
* answer from the matched chunk *plus its neighbourhood* (±2 for
|
|
21
|
+
* `INCLUDE_ADJACENT_SECTIONS`, ±5 for `FULL_DOCUMENT`), so the centre chunk
|
|
22
|
+
* alone is not enough — `CHUNK_EVIDENCE_WINDOW` defaults to 2.
|
|
23
|
+
* - A gateway in front of Onyx returns 429 under concurrency; one retry after a
|
|
24
|
+
* short delay clears it.
|
|
25
|
+
* - 404 simply means "no such chunk" (past the end of the document) and ends the
|
|
26
|
+
* expansion for that document.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const CHUNK_EVIDENCE_WINDOW = 2;
|
|
30
|
+
/**
|
|
31
|
+
* Safety bound for the window. Onyx itself never expands past ±5
|
|
32
|
+
* (`FULL_DOC_NUM_CHUNKS_AROUND`), and every extra step costs two more chunk
|
|
33
|
+
* requests per document, so a nonsensical CMS value is clamped rather than
|
|
34
|
+
* turned into a request storm against a rate-limited gateway.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_CHUNK_EVIDENCE_WINDOW = 20;
|
|
37
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
38
|
+
const RETRY_DELAY_MS = 1000;
|
|
39
|
+
|
|
40
|
+
export interface ChunkEvidence {
|
|
41
|
+
/** Joined chunk text, empty string when nothing could be resolved. */
|
|
42
|
+
text: string;
|
|
43
|
+
/** How many chunks were fetched. */
|
|
44
|
+
chunks: number;
|
|
45
|
+
/**
|
|
46
|
+
* `chunk` – real Onyx chunk text, safe to verify against.
|
|
47
|
+
* `snippet` – only the search blurb is available; absence of a claim in it is
|
|
48
|
+
* NOT evidence of hallucination.
|
|
49
|
+
*/
|
|
50
|
+
kind: 'chunk' | 'snippet';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ChunkEvidenceMap = Record<string, ChunkEvidence>;
|
|
54
|
+
|
|
55
|
+
type ChunkInfoBody = {
|
|
56
|
+
content?: string;
|
|
57
|
+
num_tokens?: number;
|
|
58
|
+
// Error envelopes: Onyx uses `detail`, the gateway in front of it uses `error`.
|
|
59
|
+
detail?: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type FetchLike = (url: string) => Promise<{
|
|
64
|
+
status: number;
|
|
65
|
+
json: () => Promise<ChunkInfoBody>;
|
|
66
|
+
}>;
|
|
67
|
+
|
|
68
|
+
const RATE_LIMIT_PATTERN = /rate limit|slow down/i;
|
|
69
|
+
|
|
70
|
+
const sleep = (ms: number) =>
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
setTimeout(resolve, ms);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Proxy path handled by `src/middleware.js` (`/_da/...` -> Onyx `/api/...`). */
|
|
76
|
+
export function chunkInfoUrl(documentId: string, chunkInd: number): string {
|
|
77
|
+
return `/_da/document/chunk-info?document_id=${encodeURIComponent(
|
|
78
|
+
documentId,
|
|
79
|
+
)}&chunk_id=${chunkInd}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch a single chunk. Returns null for "no such chunk" and for any
|
|
84
|
+
* transport/HTTP failure, retrying once on 429.
|
|
85
|
+
*
|
|
86
|
+
* The response body is authoritative, not the status: the Volto proxy pipes
|
|
87
|
+
* Onyx bodies through without the upstream status, so a 404 arrives as
|
|
88
|
+
* `200 + {"detail": "Chunk not found"}` and a gateway throttle as
|
|
89
|
+
* `200 + {"error": "Rate limit exceeded…"}`. A chunk with no `content` is never
|
|
90
|
+
* usable evidence, whatever the status says.
|
|
91
|
+
*/
|
|
92
|
+
export async function fetchChunk(
|
|
93
|
+
documentId: string,
|
|
94
|
+
chunkInd: number,
|
|
95
|
+
{
|
|
96
|
+
fetchImpl,
|
|
97
|
+
retryDelay = RETRY_DELAY_MS,
|
|
98
|
+
}: { fetchImpl?: FetchLike; retryDelay?: number } = {},
|
|
99
|
+
): Promise<string | null> {
|
|
100
|
+
const doFetch: FetchLike = fetchImpl || ((url: string) => fetch(url));
|
|
101
|
+
|
|
102
|
+
const attempt = async (): Promise<{
|
|
103
|
+
status: number;
|
|
104
|
+
text: string;
|
|
105
|
+
} | null> => {
|
|
106
|
+
try {
|
|
107
|
+
const response = await doFetch(chunkInfoUrl(documentId, chunkInd));
|
|
108
|
+
if (response.status === 429) {
|
|
109
|
+
return { status: 429, text: '' };
|
|
110
|
+
}
|
|
111
|
+
if (response.status !== 200) {
|
|
112
|
+
return { status: response.status, text: '' };
|
|
113
|
+
}
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
if (typeof body?.content === 'string' && body.content !== '') {
|
|
116
|
+
return { status: 200, text: body.content };
|
|
117
|
+
}
|
|
118
|
+
const message = String(body?.error || body?.detail || '');
|
|
119
|
+
return {
|
|
120
|
+
status: RATE_LIMIT_PATTERN.test(message) ? 429 : 404,
|
|
121
|
+
text: '',
|
|
122
|
+
};
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let result = await attempt();
|
|
129
|
+
if (result && result.status === 429) {
|
|
130
|
+
await sleep(retryDelay);
|
|
131
|
+
result = await attempt();
|
|
132
|
+
}
|
|
133
|
+
if (!result || result.status !== 200) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return result.text;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Normalise a configured window: whole number between 0 and
|
|
141
|
+
* `MAX_CHUNK_EVIDENCE_WINDOW`, falling back to the default for junk input.
|
|
142
|
+
* The value comes from a Volto block field, so it can be anything an editor typed.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeWindow(window: number): number {
|
|
145
|
+
const value = Number(window);
|
|
146
|
+
if (!Number.isFinite(value)) {
|
|
147
|
+
return CHUNK_EVIDENCE_WINDOW;
|
|
148
|
+
}
|
|
149
|
+
return Math.max(0, Math.min(Math.floor(value), MAX_CHUNK_EVIDENCE_WINDOW));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fetch the matched chunk plus `window` chunks on each side, stopping at the
|
|
154
|
+
* first missing chunk above the centre (the end of the document).
|
|
155
|
+
*/
|
|
156
|
+
export async function fetchDocumentEvidence(
|
|
157
|
+
documentId: string,
|
|
158
|
+
centerChunkInd: number,
|
|
159
|
+
{
|
|
160
|
+
window = CHUNK_EVIDENCE_WINDOW,
|
|
161
|
+
fetchImpl,
|
|
162
|
+
retryDelay,
|
|
163
|
+
}: { window?: number; fetchImpl?: FetchLike; retryDelay?: number } = {},
|
|
164
|
+
): Promise<ChunkEvidence> {
|
|
165
|
+
const center = Math.max(0, Number(centerChunkInd) || 0);
|
|
166
|
+
const win = normalizeWindow(window);
|
|
167
|
+
const lower = Math.max(0, center - win);
|
|
168
|
+
const upper = center + win;
|
|
169
|
+
|
|
170
|
+
const inds: number[] = [];
|
|
171
|
+
for (let ind = lower; ind <= upper; ind++) {
|
|
172
|
+
inds.push(ind);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const texts: string[] = [];
|
|
176
|
+
for (const ind of inds) {
|
|
177
|
+
const content = await fetchChunk(documentId, ind, {
|
|
178
|
+
fetchImpl,
|
|
179
|
+
retryDelay,
|
|
180
|
+
});
|
|
181
|
+
if (content === null) {
|
|
182
|
+
// Past the end of the document — everything above is missing too.
|
|
183
|
+
if (ind > center) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
texts.push(content);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
text: texts.join('\n'),
|
|
193
|
+
chunks: texts.length,
|
|
194
|
+
kind: texts.length > 0 ? 'chunk' : 'snippet',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve evidence for a set of documents, a few at a time.
|
|
200
|
+
*
|
|
201
|
+
* Accepts anything carrying `document_id` + `chunk_ind` (the Onyx documents, or
|
|
202
|
+
* the `halloumiSource`-shaped objects built for the fact-checker).
|
|
203
|
+
*/
|
|
204
|
+
export async function fetchEvidence(
|
|
205
|
+
docs: any[],
|
|
206
|
+
{
|
|
207
|
+
window = CHUNK_EVIDENCE_WINDOW,
|
|
208
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
209
|
+
fetchImpl,
|
|
210
|
+
retryDelay,
|
|
211
|
+
}: {
|
|
212
|
+
window?: number;
|
|
213
|
+
concurrency?: number;
|
|
214
|
+
fetchImpl?: FetchLike;
|
|
215
|
+
retryDelay?: number;
|
|
216
|
+
} = {},
|
|
217
|
+
): Promise<ChunkEvidenceMap> {
|
|
218
|
+
const unique: any[] = [];
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
(docs || []).forEach((doc: any) => {
|
|
221
|
+
const documentId = doc?.document_id;
|
|
222
|
+
if (!documentId || seen.has(documentId)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
seen.add(documentId);
|
|
226
|
+
unique.push(doc);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const evidence: ChunkEvidenceMap = {};
|
|
230
|
+
let cursor = 0;
|
|
231
|
+
|
|
232
|
+
const worker = async () => {
|
|
233
|
+
while (cursor < unique.length) {
|
|
234
|
+
const current = cursor++;
|
|
235
|
+
const doc = unique[current];
|
|
236
|
+
evidence[doc.document_id] = await fetchDocumentEvidence(
|
|
237
|
+
doc.document_id,
|
|
238
|
+
doc.chunk_ind,
|
|
239
|
+
{ window, fetchImpl, retryDelay },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const workers = Array.from(
|
|
245
|
+
{ length: Math.max(1, Math.min(concurrency, unique.length)) },
|
|
246
|
+
worker,
|
|
247
|
+
);
|
|
248
|
+
await Promise.all(workers);
|
|
249
|
+
|
|
250
|
+
return evidence;
|
|
251
|
+
}
|
package/src/ChatBlock/style.less
CHANGED
|
@@ -693,13 +693,35 @@ mark {
|
|
|
693
693
|
font-size: 14px;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
// The underline on a checked claim carries the verdict, and hue alone cannot do
|
|
697
|
+
// that job: for red-green colour blindness the good and the bad end of the ramp
|
|
698
|
+
// are the same colour. So hue and line style change together -
|
|
699
|
+
// contradicted ~~~~ wavy ~~~~ red
|
|
700
|
+
// not_enough_info ···· dotted ···· yellow
|
|
701
|
+
// supported ---- dashed ---- green
|
|
702
|
+
// The stroke uses `--claim-stroke-color` (see `components/markdown/colors.less`),
|
|
703
|
+
// a darkened variant of the hue: these hues are fine as large fills but a 2px
|
|
704
|
+
// line of yellow-500 is 1.92:1 against white and green-500 is 2.28:1.
|
|
696
705
|
.claim {
|
|
697
706
|
position: relative;
|
|
698
707
|
display: inline;
|
|
699
708
|
cursor: pointer;
|
|
700
|
-
text-decoration:
|
|
701
|
-
text-decoration-
|
|
702
|
-
text-decoration-
|
|
709
|
+
text-decoration-color: var(--claim-stroke-color);
|
|
710
|
+
text-decoration-line: underline;
|
|
711
|
+
text-decoration-thickness: 2px;
|
|
712
|
+
text-underline-offset: 0.2em;
|
|
713
|
+
|
|
714
|
+
&.claim-red-500 {
|
|
715
|
+
text-decoration-style: wavy;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
&.claim-yellow-500 {
|
|
719
|
+
text-decoration-style: dotted;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
&.claim-green-500 {
|
|
723
|
+
text-decoration-style: dashed;
|
|
724
|
+
}
|
|
703
725
|
|
|
704
726
|
&:hover {
|
|
705
727
|
background-color: var(--claim-lighter-color);
|
|
@@ -753,7 +775,7 @@ mark {
|
|
|
753
775
|
padding: 1em 1.25em;
|
|
754
776
|
border: none;
|
|
755
777
|
border-radius: 0 @borderRadius @borderRadius 0;
|
|
756
|
-
border-left: 4px solid var(--claim-color);
|
|
778
|
+
border-left: 4px solid var(--claim-stroke-color);
|
|
757
779
|
margin: 0;
|
|
758
780
|
background-color: @gray-50;
|
|
759
781
|
color: @grey;
|
|
@@ -813,19 +835,51 @@ mark {
|
|
|
813
835
|
text-transform: uppercase;
|
|
814
836
|
}
|
|
815
837
|
|
|
816
|
-
|
|
817
|
-
|
|
838
|
+
// Three discrete positions instead of a proportional fill: a per-claim score is
|
|
839
|
+
// a verdict, not a probability. The old bar made not_enough_info read as
|
|
840
|
+
// "40% verified", which was never measured.
|
|
841
|
+
.verdict-scale {
|
|
842
|
+
display: flex;
|
|
818
843
|
width: 100%;
|
|
844
|
+
gap: 4px;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.verdict-step {
|
|
819
848
|
height: 6px;
|
|
849
|
+
flex: 1;
|
|
820
850
|
border-radius: 3px;
|
|
821
851
|
background-color: @gray-200;
|
|
852
|
+
// An inset ring rather than a border: the filled step is a thin bar of a hue
|
|
853
|
+
// that is under 3:1 against white (yellow-500 is 1.92:1), so without an
|
|
854
|
+
// outline the middle position - the one that needs pointing out most - reads
|
|
855
|
+
// as empty. No layout shift, unlike a border.
|
|
856
|
+
box-shadow: inset 0 0 0 1px @gray-300;
|
|
822
857
|
}
|
|
823
858
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
859
|
+
// The three hues match the claim underlines: red contradicted, yellow
|
|
860
|
+
// not_enough_info, green supported. Colour is decoration here - which step is
|
|
861
|
+
// filled is the finding, and it reads with no colour vision at all.
|
|
862
|
+
.verdict-scale[data-verdict='contradicted'] .verdict-step:nth-child(1) {
|
|
863
|
+
background-color: @red-500;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Yellow, not red: sources saying nothing is not the same finding as sources
|
|
867
|
+
// saying something else. Grey is reserved for the `unknown` state below, where
|
|
868
|
+
// there is no verdict at all.
|
|
869
|
+
.verdict-scale[data-verdict='not_enough_info'] .verdict-step:nth-child(2) {
|
|
870
|
+
background-color: @yellow-500;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.verdict-scale[data-verdict='supported'] .verdict-step:nth-child(3) {
|
|
874
|
+
background-color: @green-500;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Checked over search snippets only: no position can be claimed.
|
|
878
|
+
.verdict-scale[data-verdict='unknown'] .verdict-step {
|
|
879
|
+
height: 4px;
|
|
880
|
+
border: 1px dashed @gray-400;
|
|
881
|
+
background-color: transparent;
|
|
882
|
+
box-shadow: none; // the dashed outline already carries the shape
|
|
829
883
|
}
|
|
830
884
|
|
|
831
885
|
.rationale-section {
|
|
@@ -153,6 +153,12 @@ export interface ChatMessageProps {
|
|
|
153
153
|
persona?: number;
|
|
154
154
|
maxContextSegments?: number;
|
|
155
155
|
batchSize?: number;
|
|
156
|
+
/**
|
|
157
|
+
* How many Onyx chunks to pull around the matched one when resolving real
|
|
158
|
+
* document text for the fact-checker (0 = centre chunk only, 5 = the widest
|
|
159
|
+
* expansion Onyx itself uses). See `services/chunkEvidence.ts`.
|
|
160
|
+
*/
|
|
161
|
+
chunkEvidenceWindow?: number;
|
|
156
162
|
isLastMessage?: boolean;
|
|
157
163
|
className?: string;
|
|
158
164
|
chatWindowRef?: React.RefObject<HTMLDivElement>;
|
|
@@ -59,13 +59,63 @@ export const useCopyToClipboard = (text: string): [boolean, () => void] => {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* The fact-checker collapses its three verdicts into a per-claim score and does
|
|
63
|
+
* not send the verdict itself (`verdict_scores` in `rag_facts_check/server.py`:
|
|
64
|
+
* supported 1.0, not_enough_info 0.4, contradicted 0.0). The thresholds below are
|
|
65
|
+
* therefore part of the contract — keep them in step with the backend.
|
|
64
66
|
*/
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return '
|
|
67
|
+
export type ClaimVerdict = 'supported' | 'not_enough_info' | 'contradicted';
|
|
68
|
+
|
|
69
|
+
export function scoreToVerdict(score: number): ClaimVerdict {
|
|
70
|
+
if (score >= 0.8) return 'supported';
|
|
71
|
+
if (score >= 0.1) return 'not_enough_info';
|
|
72
|
+
return 'contradicted';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Modal header copy. Names the outcome, never the process: "Verified Claim"
|
|
77
|
+
* reads as "this is true" even when the check contradicted the claim, and the
|
|
78
|
+
* old High/Low/Failed badge read as confidence levels for what are three
|
|
79
|
+
* discrete verdicts.
|
|
80
|
+
*/
|
|
81
|
+
const VERDICT_HEADER: Record<ClaimVerdict, string> = {
|
|
82
|
+
supported: 'Supported by the sources',
|
|
83
|
+
not_enough_info: 'Not confirmed by the sources',
|
|
84
|
+
contradicted: 'Contradicted by the sources',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const VERDICT_BADGE: Record<ClaimVerdict, string> = {
|
|
88
|
+
supported: 'Supported',
|
|
89
|
+
not_enough_info: 'Not confirmed',
|
|
90
|
+
contradicted: 'Contradicted',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function claimHeaderLabel(
|
|
94
|
+
score: number,
|
|
95
|
+
contextLimited = false,
|
|
96
|
+
): string {
|
|
97
|
+
// Checked over search snippets only: the supporting text may simply have been
|
|
98
|
+
// missing, so the verdict is not trustworthy enough to name.
|
|
99
|
+
if (contextLimited) return "Couldn't be checked";
|
|
100
|
+
return VERDICT_HEADER[scoreToVerdict(score)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function claimBadgeLabel(score: number, contextLimited = false): string {
|
|
104
|
+
if (contextLimited) return 'Unverified';
|
|
105
|
+
return VERDICT_BADGE[scoreToVerdict(score)];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which of the three positions the verdict indicator marks, or `unknown` when the
|
|
110
|
+
* check ran over snippets only and no verdict can be named.
|
|
111
|
+
*/
|
|
112
|
+
export type ClaimScaleState = ClaimVerdict | 'unknown';
|
|
113
|
+
|
|
114
|
+
export function claimScaleState(
|
|
115
|
+
score: number,
|
|
116
|
+
contextLimited = false,
|
|
117
|
+
): ClaimScaleState {
|
|
118
|
+
return contextLimited ? 'unknown' : scoreToVerdict(score);
|
|
69
119
|
}
|
|
70
120
|
|
|
71
121
|
export function convertToPercentage(
|
package/src/halloumi/README.md
CHANGED
|
@@ -14,6 +14,97 @@ Frontend (useQualityMarkers.js)
|
|
|
14
14
|
|
|
15
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
16
|
|
|
17
|
+
## Multi-evidence claims
|
|
18
|
+
|
|
19
|
+
A claim can be supported by several evidence quotes (the fact-checker returns up to
|
|
20
|
+
3 per claim), so `claim.segmentIds` is a list and each segment carries offsets into
|
|
21
|
+
the **joined** sources string:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"claims": [{ "segmentIds": ["0", "1", "2"], "score": 1.0 }],
|
|
26
|
+
"segments": {
|
|
27
|
+
"0": { "id": 0, "startOffset": 120, "endOffset": 190 },
|
|
28
|
+
"1": { "id": 1, "startOffset": 402, "endOffset": 455 },
|
|
29
|
+
"2": { "id": 2, "startOffset": 980, "endOffset": 1030 }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`ClaimSegments` groups those segments per source (one tab per cited document, one
|
|
35
|
+
"Jump to citation" chip per segment) and `RenderClaimView` highlights them inside the
|
|
36
|
+
source text. Segments are half-open `[start, end)`; overlapping ones are clipped so
|
|
37
|
+
the source text is never rendered twice.
|
|
38
|
+
|
|
39
|
+
## Source text: Onyx chunks, not search blurbs
|
|
40
|
+
|
|
41
|
+
The Onyx v3 chat stream carries documents as `SearchDoc` objects, which have **no
|
|
42
|
+
`content` field** — only a ~600-character `blurb`. Sending blurbs makes supported
|
|
43
|
+
claims look hallucinated, because the answer was written from full chunk text.
|
|
44
|
+
|
|
45
|
+
`ChatBlock/services/chunkEvidence.ts` therefore resolves the real text before the
|
|
46
|
+
fact-check runs, through the existing `/_da` proxy:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
GET /_da/document/chunk-info?document_id=<id>&chunk_id=<n>
|
|
50
|
+
-> Onyx GET /api/document/chunk-info -> { "content": "<chunk text>", "num_tokens": 512 }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Both parameters are already present on every streamed document (`document_id`,
|
|
54
|
+
`chunk_ind`), so no extra data is needed from Onyx.
|
|
55
|
+
- The matched chunk is fetched together with `chunkEvidenceWindow` chunks on each
|
|
56
|
+
side (default **2**, i.e. what Onyx's `INCLUDE_ADJACENT_SECTIONS` expansion uses).
|
|
57
|
+
The centre chunk alone is not enough — the claims usually sit in its
|
|
58
|
+
neighbourhood. Onyx's widest expansion is ±5; raise the value to match it.
|
|
59
|
+
- The window is also a block setting — **Evidence window** in the chat block sidebar —
|
|
60
|
+
so it can be tuned per block without a deploy. Values outside 0–20 are clamped
|
|
61
|
+
(`MAX_CHUNK_EVIDENCE_WINDOW`): each extra step costs two more chunk requests per
|
|
62
|
+
document against a rate-limited gateway.
|
|
63
|
+
- A gateway in front of Onyx returns **429** under concurrency, so requests run
|
|
64
|
+
through a small pool with one retry after 1s. **404** means "no such chunk" and
|
|
65
|
+
simply ends the expansion for that document.
|
|
66
|
+
- `useChunkEvidence` gates the fact-check call on `evidenceSettled`, so a check is
|
|
67
|
+
never fired on blurbs in the same commit that started the fetch. If resolution
|
|
68
|
+
fails, verification still runs on blurbs rather than never running.
|
|
69
|
+
|
|
70
|
+
Each source sent to the fact-checker carries `kind`:
|
|
71
|
+
|
|
72
|
+
| `kind` | meaning |
|
|
73
|
+
| --------- | --------------------------------------------------------------------- |
|
|
74
|
+
| `chunk` | real Onyx chunk text — a claim missing from it is meaningful evidence |
|
|
75
|
+
| `snippet` | only the search blurb is available — a missing claim proves nothing |
|
|
76
|
+
|
|
77
|
+
The fact-checker answers with a `context_quality` block describing what it was
|
|
78
|
+
actually given:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"level": "partial",
|
|
83
|
+
"sources": 4,
|
|
84
|
+
"chunk_sources": 3,
|
|
85
|
+
"snippet_sources": 1,
|
|
86
|
+
"note": "1 of 4 sources were search snippets, not full document text…"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| `level` | meaning |
|
|
91
|
+
| --------- | ----------------------------------------------------------- |
|
|
92
|
+
| `full` | every source was `chunk` text — the score is a real verdict |
|
|
93
|
+
| `partial` | at least one source was a `snippet` |
|
|
94
|
+
| `unknown` | the backend reported no level (older deployment) |
|
|
95
|
+
| `none` | no sources reached the checker |
|
|
96
|
+
|
|
97
|
+
In `partial` mode each `not_enough_info` claim additionally carries
|
|
98
|
+
`context_limited: true`. Both signals are rendered: `HalloumiFeedback` shows a
|
|
99
|
+
"Partial context" notice under the score, and `ClaimModal` labels such a claim
|
|
100
|
+
`Unverified / Partial context` instead of `Low / Verification`. The numeric score is
|
|
101
|
+
deliberately left alone — the label carries the caveat, the number stays comparable
|
|
102
|
+
across runs.
|
|
103
|
+
|
|
104
|
+
`halloumiContext` (used for segment highlighting) and `halloumiSource.text` (used by
|
|
105
|
+
the fact-checker for offsets) are always the same nbsp-cleaned string. Changing one
|
|
106
|
+
without the other breaks highlighting.
|
|
107
|
+
|
|
17
108
|
## Configuration
|
|
18
109
|
|
|
19
110
|
| Environment Variable | Default | Description |
|
package/src/index.js
CHANGED
|
@@ -57,5 +57,6 @@ export * from './ChatBlock/packets';
|
|
|
57
57
|
export { default as UserActionsToolbar } from './ChatBlock/components/UserActionsToolbar';
|
|
58
58
|
export { default as FeedbackModal } from './ChatBlock/components/FeedbackModal';
|
|
59
59
|
export { default as ChatMessageFeedback } from './ChatBlock/components/ChatMessageFeedback';
|
|
60
|
+
export { isAISearchInputRestricted } from './AISearchInput';
|
|
60
61
|
|
|
61
62
|
export default applyConfig;
|
package/src/middleware.js
CHANGED
|
@@ -18,6 +18,9 @@ const ALLOWED_PROXY_PATHS = [
|
|
|
18
18
|
{ path: '/chat/send-message', methods: ['POST'] },
|
|
19
19
|
{ path: '/chat/send-chat-message', methods: ['POST'] },
|
|
20
20
|
{ path: '/chat/create-chat-message-feedback', methods: ['POST'] },
|
|
21
|
+
// Real document text behind an answer, used to give the fact-checker the same
|
|
22
|
+
// text the answer generator read (see ChatBlock/services/chunkEvidence.ts).
|
|
23
|
+
{ pathPattern: /^\/document\/chunk-info$/, methods: ['GET'] },
|
|
21
24
|
];
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -259,6 +262,12 @@ async function send_onyx_request(
|
|
|
259
262
|
log(`Dumped LLM response to: ${filePath}`);
|
|
260
263
|
}
|
|
261
264
|
|
|
265
|
+
// Propagate the upstream status. Without this, Express defaults to 200 and
|
|
266
|
+
// every Onyx error (404 chunk not found, 429 rate limit, 401 revoked key)
|
|
267
|
+
// reaches the browser as a successful response, which makes the evidence
|
|
268
|
+
// client unable to tell "missing" from "found".
|
|
269
|
+
res.status(response.status);
|
|
270
|
+
|
|
262
271
|
if (!api_key) {
|
|
263
272
|
if (response.headers.get('transfer-encoding') === 'chunked') {
|
|
264
273
|
res.set('Content-Type', 'text/event-stream');
|
package/src/middleware.test.js
CHANGED
|
@@ -174,6 +174,62 @@ describe('src/middleware', () => {
|
|
|
174
174
|
expect(lastCall[1].body).toBeUndefined();
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
// Express defaults to 200 when the proxy never sets a status, so every Onyx
|
|
178
|
+
// error reached the browser as a success. The evidence client cannot tell a
|
|
179
|
+
// missing chunk from a found one (or a gateway throttle from a response) unless
|
|
180
|
+
// the upstream status survives the proxy.
|
|
181
|
+
it('propagates an upstream 404 instead of flattening it to 200', async () => {
|
|
182
|
+
process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
|
|
183
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
184
|
+
req.method = 'GET';
|
|
185
|
+
req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=9';
|
|
186
|
+
req.body = null;
|
|
187
|
+
nodeFetch.mockResolvedValueOnce({
|
|
188
|
+
status: 404,
|
|
189
|
+
headers: {
|
|
190
|
+
get: jest.fn().mockReturnValue('application/json'),
|
|
191
|
+
raw: jest.fn().mockReturnValue({}),
|
|
192
|
+
},
|
|
193
|
+
body: { pipe: jest.fn() },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await middleware(req, res, next);
|
|
197
|
+
|
|
198
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('propagates an upstream 429 so the client can retry it', async () => {
|
|
202
|
+
process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
|
|
203
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
204
|
+
req.method = 'GET';
|
|
205
|
+
req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=0';
|
|
206
|
+
req.body = null;
|
|
207
|
+
nodeFetch.mockResolvedValueOnce({
|
|
208
|
+
status: 429,
|
|
209
|
+
headers: {
|
|
210
|
+
get: jest.fn().mockReturnValue('application/json'),
|
|
211
|
+
raw: jest.fn().mockReturnValue({}),
|
|
212
|
+
},
|
|
213
|
+
body: { pipe: jest.fn() },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await middleware(req, res, next);
|
|
217
|
+
|
|
218
|
+
expect(res.status).toHaveBeenCalledWith(429);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('answers 200 for a successful proxied chunk', async () => {
|
|
222
|
+
process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
|
|
223
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
224
|
+
req.method = 'GET';
|
|
225
|
+
req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=0';
|
|
226
|
+
req.body = null;
|
|
227
|
+
|
|
228
|
+
await middleware(req, res, next);
|
|
229
|
+
|
|
230
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
231
|
+
});
|
|
232
|
+
|
|
177
233
|
it('uses mock create-chat-session when MOCK_LLM_FILE_PATH is set', async () => {
|
|
178
234
|
process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
|
|
179
235
|
process.env.ONYX_URL = 'http://localhost:3000';
|