Skip to content

Commit 819e69d

Browse files
danielecaldaclaude
andcommitted
Issue #2134: Show guardrail block message in chat
When the input guardrail blocks a request, the backend emits a GUARDRAIL SSE event with no START/CHUNK, so chat frontends showed nothing and the loading spinner never stopped. Handle the GUARDRAIL event in talk-to, search-frontend and openk9-chatbot: show a generic translated block message and stop the loading/chatting state. Add the guardrail-violation translation key (en, it, de, fr, es) and cover the talk-to hook with jest tests. Fixes #2134 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5a1e861 commit 819e69d

15 files changed

Lines changed: 205 additions & 3 deletions

js-packages/openk9-chatbot/lib/components/Translate.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { useLanguage } from "./useLanguage";
1818

1919
export function Translate({ label }: { label: TranslationKey }): string {
2020
const { language } = useLanguage();
21+
return getTranslation(label, language);
22+
}
23+
24+
export function getTranslation(label: TranslationKey, language: string): string {
2125
const htmlLang = language as Language;
2226
const translation = translations[label]?.[htmlLang];
2327
return translation || label;
@@ -82,6 +86,13 @@ const translations: {
8286
es_ES: "Buscar en el chatbot",
8387
de_DE: "Im Chatbot suchen",
8488
},
89+
guardrailViolation: {
90+
it_IT: "La tua richiesta non può essere evasa perché viola le linee guida sui contenuti.",
91+
en_US: "Your request cannot be processed because it violates the content guidelines.",
92+
fr_FR: "Votre demande ne peut pas être traitée car elle enfreint les règles relatives au contenu.",
93+
es_ES: "Tu solicitud no puede ser procesada porque infringe las directrices de contenido.",
94+
de_DE: "Ihre Anfrage kann nicht bearbeitet werden, da sie gegen die Inhaltsrichtlinien verstößt.",
95+
},
8596
};
8697

8798
type Language = "it_IT" | "en_US" | "fr_FR" | "es_ES" | "de_DE";
@@ -94,5 +105,6 @@ type TranslationKey =
94105
| "youSendMessage"
95106
| "sendMessage"
96107
| "customPlaceholder"
97-
| "searchLabel";
108+
| "searchLabel"
109+
| "guardrailViolation";
98110

js-packages/openk9-chatbot/lib/components/useGenerateResponse.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import React, { useState, useCallback } from "react";
1818
import { v4 as uuidv4 } from "uuid";
1919
import { useLanguage } from "./useLanguage";
20+
import { getTranslation } from "./Translate";
2021
import { OpenK9Client } from "./client";
2122

2223
type Source = { source?: string; title?: string; url?: string };
@@ -203,6 +204,24 @@ const useGenerateResponse = ({
203204
setIsChatting(false);
204205
break;
205206

207+
case "GUARDRAIL":
208+
setMessages((prev) =>
209+
prev.map((msg) =>
210+
msg.id === id
211+
? {
212+
...msg,
213+
answer: getTranslation(
214+
"guardrailViolation",
215+
language,
216+
),
217+
}
218+
: msg,
219+
),
220+
);
221+
setIsChatting(false);
222+
setIsLoading(null);
223+
break;
224+
206225
default:
207226
console.warn(
208227
"Tipo di chunk non riconosciuto:",

js-packages/search-frontend/src/components/useGenerateResponse.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useCallback } from "react";
2+
import { useTranslation } from "react-i18next";
23
import { GenerateRequest, useOpenK9Client } from "./client";
34

45
type Source = { source?: string; title?: string; url?: string };
@@ -38,6 +39,7 @@ const useGenerateResponse = ({
3839
useState<AbortController | null>(null);
3940
const [isChatting, setIsChatting] = useState<boolean>(false);
4041
const client: Client = useOpenK9Client();
42+
const { t } = useTranslation();
4143

4244
useEffect(() => {
4345
if (initialMessages.length > 0) {
@@ -150,6 +152,19 @@ const useGenerateResponse = ({
150152
setIsChatting(false);
151153
setIsRequestLoading?.(false);
152154
break;
155+
case "GUARDRAIL":
156+
setMessage((prev) =>
157+
prev
158+
? {
159+
...prev,
160+
answer: t("guardrail-violation"),
161+
status: "CHUNK",
162+
}
163+
: prev,
164+
);
165+
setIsChatting(false);
166+
setIsRequestLoading?.(false);
167+
break;
153168
default:
154169
if (typeof data.chunk === "string") {
155170
if (!sawAnyChunk) {
@@ -222,7 +237,7 @@ const useGenerateResponse = ({
222237

223238
setAbortController(null);
224239
},
225-
[client, setIsRequestLoading],
240+
[client, setIsRequestLoading, t],
226241
);
227242

228243
const cancelAllResponses = useCallback(() => {

js-packages/search-frontend/src/translations/translation_de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"translation": {
33
"after-days": "Tage vorwärts",
4+
"guardrail-violation": "Ihre Anfrage kann nicht bearbeitet werden, da sie gegen die Inhaltsrichtlinien verstößt.",
45
"active-filters": "Aktive Filter",
56
"asc": "aufsteigend",
67
"add-filters": "Filter anwenden",

js-packages/search-frontend/src/translations/translation_en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"translation": {
33
"after-days": "days forward",
4+
"guardrail-violation": "Your request cannot be processed because it violates the content guidelines.",
45
"active-filters": "Active Filters",
56
"asc": "asc",
67
"add-filters": "Apply Filters",

js-packages/search-frontend/src/translations/translation_es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"translation": {
33
"after-days": "días adelante",
4+
"guardrail-violation": "Tu solicitud no puede ser procesada porque infringe las directrices de contenido.",
45
"active-filters": "Filtros Activos",
56
"asc": "creciente",
67
"any": "Cualquier",

js-packages/search-frontend/src/translations/translation_fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"translation": {
33
"after-days": "jours à venir",
4+
"guardrail-violation": "Votre demande ne peut pas être traitée car elle enfreint les règles relatives au contenu.",
45
"active-filters": "Filtres actifs",
56
"any": "N'importe quel",
67
"add-filters": "Appliquer des filtres",

js-packages/search-frontend/src/translations/translation_it.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"translation": {
33
"after-days": "giorni avanti",
4+
"guardrail-violation": "La tua richiesta non può essere evasa perché viola le linee guida sui contenuti.",
45
"active-filters": "Filtri Attivi",
56
"any": "Qualunque",
67
"add-filters": "Applica i Filtri",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { TextDecoder, TextEncoder } from "util";
2+
import { renderHook, act, waitFor } from "@testing-library/react";
3+
import useGenerateResponse from "./useGenerateResponse";
4+
5+
(global as any).TextEncoder = (global as any).TextEncoder || TextEncoder;
6+
(global as any).TextDecoder = (global as any).TextDecoder || TextDecoder;
7+
8+
let mockUuidCounter = 0;
9+
jest.mock("uuid", () => ({
10+
v4: () => `test-uuid-${++mockUuidCounter}`,
11+
}));
12+
13+
jest.mock("./ChatInfoContext", () => ({
14+
useUser: () => ({
15+
userInfo: { name: "test-user" },
16+
loading: false,
17+
language: "en",
18+
}),
19+
}));
20+
21+
jest.mock("../context/HistoryChatContext", () => ({
22+
useChatContext: () => ({ dispatch: jest.fn() }),
23+
}));
24+
25+
jest.mock("./keycloak", () => ({
26+
keycloak: { authenticated: false },
27+
}));
28+
29+
jest.mock("react-i18next", () => ({
30+
useTranslation: () => ({ t: (key: string) => key }),
31+
}));
32+
33+
const mockGenerateResponse = jest.fn();
34+
35+
jest.mock("./client", () => ({
36+
OpenK9Client: () => ({
37+
GenerateResponse: (...args: unknown[]) => mockGenerateResponse(...args),
38+
}),
39+
}));
40+
41+
function createSSEResponse(events: Array<Record<string, unknown>>) {
42+
const encoder = new TextEncoder();
43+
const lines = events.map((event) => `data: ${JSON.stringify(event)}\n`);
44+
let index = 0;
45+
return {
46+
ok: true,
47+
body: {
48+
getReader: () => ({
49+
read: async () => {
50+
if (index < lines.length) {
51+
return { value: encoder.encode(lines[index++]), done: false };
52+
}
53+
return { value: undefined, done: true };
54+
},
55+
}),
56+
},
57+
};
58+
}
59+
60+
const initialMessages: never[] = [];
61+
62+
describe("useGenerateResponse", () => {
63+
beforeEach(() => {
64+
mockGenerateResponse.mockReset();
65+
});
66+
67+
test("blocked query shows the guardrail block message", async () => {
68+
mockGenerateResponse.mockResolvedValue(
69+
createSSEResponse([
70+
{ type: "GUARDRAIL", chunk: "Guardrail violation - (CATEGORY)" },
71+
{ type: "END", chunk: "" },
72+
]),
73+
);
74+
75+
const { result } = renderHook(() => useGenerateResponse({ initialMessages }));
76+
77+
await act(async () => {
78+
await result.current.generateResponse("blocked query", "chat-1");
79+
});
80+
81+
await waitFor(() => {
82+
const lastMessage = result.current.messages[result.current.messages.length - 1];
83+
expect(lastMessage.answer).toBe("guardrail-violation");
84+
expect(lastMessage.status).toBe("END");
85+
});
86+
});
87+
88+
test("loading and chatting stop on guardrail block even without START event", async () => {
89+
mockGenerateResponse.mockResolvedValue(
90+
createSSEResponse([
91+
{ type: "GUARDRAIL", chunk: "Guardrail violation - (CATEGORY)" },
92+
{ type: "END", chunk: "" },
93+
]),
94+
);
95+
96+
const { result } = renderHook(() => useGenerateResponse({ initialMessages }));
97+
98+
await act(async () => {
99+
await result.current.generateResponse("blocked query", "chat-1");
100+
});
101+
102+
await waitFor(() => {
103+
expect(result.current.isLoading).toBeNull();
104+
expect(result.current.isChatting).toBe(false);
105+
});
106+
});
107+
108+
test("regular stream still accumulates chunks and completes", async () => {
109+
mockGenerateResponse.mockResolvedValue(
110+
createSSEResponse([
111+
{ type: "START", chunk: "" },
112+
{ type: "CHUNK", chunk: "Hello " },
113+
{ type: "CHUNK", chunk: "world" },
114+
{ type: "END", chunk: "" },
115+
]),
116+
);
117+
118+
const { result } = renderHook(() => useGenerateResponse({ initialMessages }));
119+
120+
await act(async () => {
121+
await result.current.generateResponse("normal query", "chat-1");
122+
});
123+
124+
await waitFor(() => {
125+
const lastMessage = result.current.messages[result.current.messages.length - 1];
126+
expect(lastMessage.answer).toBe("Hello world");
127+
expect(lastMessage.status).toBe("END");
128+
expect(result.current.isLoading).toBeNull();
129+
expect(result.current.isChatting).toBe(false);
130+
});
131+
});
132+
});

js-packages/talk-to/src/components/useGenerateResponse.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ const useGenerateResponse = ({ initialMessages }: { initialMessages: Message[] }
183183
setIsChatting(false);
184184
setIsLoading(null);
185185
break;
186+
case "GUARDRAIL":
187+
setMessages((prev) =>
188+
prev.map((msg) =>
189+
msg.id === id
190+
? {
191+
...msg,
192+
answer: t("guardrail-violation"),
193+
}
194+
: msg,
195+
),
196+
);
197+
setIsChatting(false);
198+
setIsLoading(null);
199+
break;
186200
default:
187201
console.warn("Tipo di chunk non riconosciuto:", data.type);
188202
break;
@@ -220,7 +234,7 @@ const useGenerateResponse = ({ initialMessages }: { initialMessages: Message[] }
220234
return updated;
221235
});
222236
},
223-
[loading, userInfo, messages, client, language],
237+
[loading, userInfo, messages, client, language, t],
224238
);
225239

226240
const cancelResponse = (id: string) => {

0 commit comments

Comments
 (0)