@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.
@@ -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
+ }
@@ -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: underline;
701
- text-decoration-color: var(--claim-color);
702
- text-decoration-style: wavy;
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
- .score-progress-bar {
817
- overflow: hidden;
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
- .score-progress-fill {
825
- height: 100%;
826
- border-radius: 3px;
827
- background-color: var(--claim-color);
828
- transition: width 0.3s ease;
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
- * Map a 0-1 claim score to a categorical label.
63
- * Scores: 1.0 = supported, 0.4 = not_enough_info, 0.0 = contradicted.
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 function scoreToLabel(score: number): string {
66
- if (score >= 0.8) return 'High';
67
- if (score >= 0.1) return 'Low';
68
- return 'Failed';
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(
@@ -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');
@@ -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';