@eeacms/volto-eea-chatbot 4.1.2 → 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.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +29 -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 +5 -2
- package/src/ChatBlock/components/HalloumiFeedback.jsx +19 -0
- package/src/ChatBlock/components/markdown/ClaimModal.jsx +28 -14
- 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 +22 -0
- package/src/ChatBlock/components/markdown/colors.less +10 -1
- package/src/ChatBlock/components/markdown/colors.test.js +46 -1
- package/src/ChatBlock/hooks/index.ts +1 -0
- package/src/ChatBlock/hooks/useChatController.ts +2 -2
- package/src/ChatBlock/hooks/useChunkEvidence.ts +80 -0
- package/src/ChatBlock/packets/MultiToolRenderer.tsx +7 -3
- package/src/ChatBlock/schema.jsx +13 -1
- package/src/ChatBlock/services/chunkEvidence.ts +251 -0
- package/src/ChatBlock/services/streamingService.ts +16 -0
- package/src/ChatBlock/style.less +36 -8
- 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
|
+
}
|
|
@@ -195,6 +195,22 @@ function normaliseV3Chunk(raw: any): Packet | null {
|
|
|
195
195
|
} as Packet;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// Handle Error format (special case without ind/obj)
|
|
199
|
+
if ('error' in raw) {
|
|
200
|
+
return {
|
|
201
|
+
ind: -1,
|
|
202
|
+
obj: {
|
|
203
|
+
type: PacketType.ERROR,
|
|
204
|
+
error: raw.error,
|
|
205
|
+
},
|
|
206
|
+
} as Packet;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Already normalised or v2 format: { ind: number, obj: { type: string, ... } }
|
|
210
|
+
if ('ind' in raw && typeof raw.obj === 'object' && raw.obj !== null) {
|
|
211
|
+
return raw as Packet;
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
if (!raw.placement || typeof raw.obj !== 'object') return null;
|
|
199
215
|
|
|
200
216
|
const ind: number = raw.placement.turn_index ?? 0;
|
package/src/ChatBlock/style.less
CHANGED
|
@@ -813,19 +813,47 @@ mark {
|
|
|
813
813
|
text-transform: uppercase;
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
-
|
|
817
|
-
|
|
816
|
+
// Three discrete positions instead of a proportional fill: a per-claim score is
|
|
817
|
+
// a verdict, not a probability. The old bar made not_enough_info read as
|
|
818
|
+
// "40% verified", which was never measured.
|
|
819
|
+
.verdict-scale {
|
|
820
|
+
display: flex;
|
|
818
821
|
width: 100%;
|
|
822
|
+
gap: 4px;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.verdict-step {
|
|
819
826
|
height: 6px;
|
|
827
|
+
flex: 1;
|
|
820
828
|
border-radius: 3px;
|
|
821
829
|
background-color: @gray-200;
|
|
830
|
+
// An inset ring rather than a border: the green of `not_enough_info` is about
|
|
831
|
+
// 2.8:1 against white, so without an outline the middle position - the one that
|
|
832
|
+
// needs pointing out most - reads as empty. No layout shift, unlike a border.
|
|
833
|
+
box-shadow: inset 0 0 0 1px @gray-300;
|
|
822
834
|
}
|
|
823
835
|
|
|
824
|
-
.
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
836
|
+
.verdict-scale[data-verdict='contradicted'] .verdict-step:nth-child(1) {
|
|
837
|
+
background-color: @red-500;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Not red: sources saying nothing is not the same finding as sources saying
|
|
841
|
+
// something else. green-500 rather than the chip's green-300 because a solid bar
|
|
842
|
+
// on white needs more weight than a pale background does (green-300 is 1.7:1).
|
|
843
|
+
.verdict-scale[data-verdict='not_enough_info'] .verdict-step:nth-child(2) {
|
|
844
|
+
background-color: @green-500;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.verdict-scale[data-verdict='supported'] .verdict-step:nth-child(3) {
|
|
848
|
+
background-color: @green-700;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Checked over search snippets only: no position can be claimed.
|
|
852
|
+
.verdict-scale[data-verdict='unknown'] .verdict-step {
|
|
853
|
+
height: 4px;
|
|
854
|
+
border: 1px dashed @gray-400;
|
|
855
|
+
background-color: transparent;
|
|
856
|
+
box-shadow: none; // the dashed outline already carries the shape
|
|
829
857
|
}
|
|
830
858
|
|
|
831
859
|
.rationale-section {
|
|
@@ -1887,7 +1915,7 @@ mark {
|
|
|
1887
1915
|
opacity 0.2s;
|
|
1888
1916
|
|
|
1889
1917
|
&.expanded {
|
|
1890
|
-
max-height:
|
|
1918
|
+
max-height: 3000px;
|
|
1891
1919
|
padding: 0.25rem 0.875rem;
|
|
1892
1920
|
opacity: 1;
|
|
1893
1921
|
}
|
|
@@ -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';
|