M17: ground knowledge and integration evidence
This commit is contained in:
@@ -65,11 +65,15 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/knowledge");
|
||||
|
||||
// The status badge and retrieval-flow diagram must name the actual active provider
|
||||
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
|
||||
// allowed to mention RAGcore by name when explaining it isn't live yet.
|
||||
// The status badge must name the actual active provider once its check settles.
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
|
||||
|
||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
const sources = page.locator(".exchange").first().locator(".source-card");
|
||||
expect(await sources.count()).toBeLessThanOrEqual(3);
|
||||
const titles = await sources.locator(".source-title").allTextContents();
|
||||
expect(new Set(titles.map((title) => title.trim().toLocaleLowerCase())).size).toBe(titles.length);
|
||||
await page.getByRole("button", { name: "Ja" }).first().click();
|
||||
await expect(page.getByText("Feedback opgeslagen")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"title": "Integration control",
|
||||
"description": "Monitor delivery health, graceful degradation and retryable workflow events.",
|
||||
"cards": {
|
||||
"checking": "Retrieving current integration status…",
|
||||
"orchestrationKicker": "Orchestration",
|
||||
"n8nTitle": "n8n delivery",
|
||||
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering",
|
||||
@@ -24,6 +25,7 @@
|
||||
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
|
||||
},
|
||||
"statusLabels": {
|
||||
"checking": "Checking…",
|
||||
"notConnected": "Not connected",
|
||||
"deliveryFailed": "Delivery failed",
|
||||
"retryAvailable": "Retry available",
|
||||
@@ -35,6 +37,8 @@
|
||||
"workflows": {
|
||||
"title": "Automation workflows",
|
||||
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
|
||||
"loadingEvidence": "Retrieving current workflow evidence…",
|
||||
"unavailable": "Workflow evidence is currently unavailable.",
|
||||
"notBuilt": "Not built yet",
|
||||
"noEvidence": "No evidence yet",
|
||||
"healthy": "Healthy",
|
||||
@@ -53,6 +57,12 @@
|
||||
"ragcoreSync": "Knowledge procedure sync",
|
||||
"errorHandler": "Workflow error handler"
|
||||
},
|
||||
"expectations": {
|
||||
"vehicleReturn": "Event-driven after a vehicle return is safely committed.",
|
||||
"scheduledScan": "Scheduled; normally runs every hour.",
|
||||
"ragcoreSync": "Scheduled; normally runs daily.",
|
||||
"errorHandler": "Runs only when a real workflow execution fails."
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
|
||||
"summaryEmpty": "No automation failures have been registered."
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
|
||||
"providerDemo": "the demo knowledge base",
|
||||
"providerRagcore": "RAGcore",
|
||||
"providerChecking": "the knowledge source",
|
||||
"providerUnavailable": "the knowledge service",
|
||||
"statusChecking": "Checking knowledge source…",
|
||||
"statusCheckingDetail": "Retrieving the current provider status.",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnavailable": "Unavailable",
|
||||
"proceduresIndexed": "{{count}} procedures indexed",
|
||||
@@ -16,6 +20,7 @@
|
||||
"ask": "Ask",
|
||||
"asking": "Asking…",
|
||||
"askFailed": "Could not reach the knowledge service.",
|
||||
"feedbackFailed": "Could not save feedback.",
|
||||
"suggestedLabel": "Try one:",
|
||||
"suggestedQuestions": [
|
||||
"What must I do when a vehicle returns with damage?",
|
||||
@@ -38,5 +43,12 @@
|
||||
"unavailable": "Knowledge service unavailable"
|
||||
},
|
||||
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.",
|
||||
"sourceVersion": "v{{version}}"
|
||||
"sourceVersion": "v{{version}}",
|
||||
"answeredIn": "Answered in {{seconds}} s",
|
||||
"sourceTechnicalVersion": "Technical source version available in the audit log",
|
||||
"feedbackLabel": "Answer feedback",
|
||||
"feedbackPrompt": "Was this answer useful?",
|
||||
"feedbackThanks": "Feedback saved",
|
||||
"feedbackHelpful": "Yes",
|
||||
"feedbackNotHelpful": "No"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"title": "Contrôle des intégrations",
|
||||
"description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.",
|
||||
"cards": {
|
||||
"checking": "Récupération de l’état actuel des intégrations…",
|
||||
"orchestrationKicker": "Orchestration",
|
||||
"n8nTitle": "Livraison n8n",
|
||||
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours",
|
||||
@@ -24,6 +25,7 @@
|
||||
"n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration."
|
||||
},
|
||||
"statusLabels": {
|
||||
"checking": "Vérification…",
|
||||
"notConnected": "Non connecté",
|
||||
"deliveryFailed": "Livraison échouée",
|
||||
"retryAvailable": "Nouvelle tentative possible",
|
||||
@@ -35,6 +37,8 @@
|
||||
"workflows": {
|
||||
"title": "Workflows d'automatisation",
|
||||
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.",
|
||||
"loadingEvidence": "Récupération des preuves actuelles des workflows…",
|
||||
"unavailable": "Les preuves des workflows sont actuellement indisponibles.",
|
||||
"notBuilt": "Pas encore créé",
|
||||
"noEvidence": "Aucune preuve pour l’instant",
|
||||
"healthy": "Sain",
|
||||
@@ -53,6 +57,12 @@
|
||||
"ragcoreSync": "Synchronisation des procédures de connaissances",
|
||||
"errorHandler": "Gestionnaire d'erreurs de workflow"
|
||||
},
|
||||
"expectations": {
|
||||
"vehicleReturn": "Déclenché après l’enregistrement sécurisé d’un retour de véhicule.",
|
||||
"scheduledScan": "Planifié ; s’exécute normalement chaque heure.",
|
||||
"ragcoreSync": "Planifié ; s’exécute normalement chaque jour.",
|
||||
"errorHandler": "S’exécute uniquement lorsqu’un workflow réel échoue."
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
|
||||
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Posez des questions opérationnelles. Les réponses ne s'affichent que lorsque {{provider}} renvoie suffisamment de preuves citées.",
|
||||
"providerDemo": "la base de connaissances de démo",
|
||||
"providerRagcore": "RAGcore",
|
||||
"providerChecking": "la source de connaissances",
|
||||
"providerUnavailable": "le service de connaissances",
|
||||
"statusChecking": "Vérification de la source…",
|
||||
"statusCheckingDetail": "Récupération de l’état actuel du fournisseur.",
|
||||
"statusAvailable": "Disponible",
|
||||
"statusUnavailable": "Indisponible",
|
||||
"proceduresIndexed": "{{count}} procédures indexées",
|
||||
@@ -16,6 +20,7 @@
|
||||
"ask": "Demander",
|
||||
"asking": "Question en cours…",
|
||||
"askFailed": "Impossible de joindre le service de connaissances.",
|
||||
"feedbackFailed": "Impossible d’enregistrer le retour.",
|
||||
"suggestedLabel": "Essayez :",
|
||||
"suggestedQuestions": [
|
||||
"Que dois-je faire quand un véhicule revient avec des dommages ?",
|
||||
@@ -38,5 +43,12 @@
|
||||
"unavailable": "Service de connaissances indisponible"
|
||||
},
|
||||
"unavailableBody": "Le service de connaissances est actuellement inaccessible. Les fonctions opérationnelles ne sont pas affectées — réessayez plus tard.",
|
||||
"sourceVersion": "v{{version}}"
|
||||
"sourceVersion": "v{{version}}",
|
||||
"answeredIn": "Réponse en {{seconds}} s",
|
||||
"sourceTechnicalVersion": "Version technique de la source disponible dans l’audit",
|
||||
"feedbackLabel": "Avis sur la réponse",
|
||||
"feedbackPrompt": "Cette réponse était-elle utile ?",
|
||||
"feedbackThanks": "Avis enregistré",
|
||||
"feedbackHelpful": "Oui",
|
||||
"feedbackNotHelpful": "Non"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"title": "Integratiebeheer",
|
||||
"description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.",
|
||||
"cards": {
|
||||
"checking": "Actuele integratiestatus ophalen…",
|
||||
"orchestrationKicker": "Orkestratie",
|
||||
"n8nTitle": "n8n-aflevering",
|
||||
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij · {{delivering}} bezig",
|
||||
@@ -24,6 +25,7 @@
|
||||
"n8nDemoScenario": "Plus {{count}} voorbereid demoscenario — een gesimuleerde tijdelijke fout, geen integratieprobleem."
|
||||
},
|
||||
"statusLabels": {
|
||||
"checking": "Controleren…",
|
||||
"notConnected": "Niet gekoppeld",
|
||||
"deliveryFailed": "Verwerking mislukt",
|
||||
"retryAvailable": "Opnieuw proberen mogelijk",
|
||||
@@ -35,6 +37,8 @@
|
||||
"workflows": {
|
||||
"title": "Automatiseringsworkflows",
|
||||
"description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.",
|
||||
"loadingEvidence": "Actuele workflowevidentie ophalen…",
|
||||
"unavailable": "Workflowevidentie is momenteel niet beschikbaar.",
|
||||
"notBuilt": "Nog niet gebouwd",
|
||||
"noEvidence": "Nog geen evidentie",
|
||||
"healthy": "Gezond",
|
||||
@@ -53,6 +57,12 @@
|
||||
"ragcoreSync": "Synchronisatie kennisprocedures",
|
||||
"errorHandler": "Workflowfoutafhandelaar"
|
||||
},
|
||||
"expectations": {
|
||||
"vehicleReturn": "Gebeurtenisgestuurd na een veilig opgeslagen voertuigretour.",
|
||||
"scheduledScan": "Gepland; normaal ieder uur actief.",
|
||||
"ragcoreSync": "Gepland; normaal dagelijks actief.",
|
||||
"errorHandler": "Wordt alleen actief wanneer een echte workflowuitvoering mislukt."
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.",
|
||||
"summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd."
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.",
|
||||
"providerDemo": "de demokennisbank",
|
||||
"providerRagcore": "RAGcore",
|
||||
"providerChecking": "de kennisbron",
|
||||
"providerUnavailable": "de kennisdienst",
|
||||
"statusChecking": "Kennisbron controleren…",
|
||||
"statusCheckingDetail": "De actuele providerstatus wordt opgehaald.",
|
||||
"statusAvailable": "Beschikbaar",
|
||||
"statusUnavailable": "Niet beschikbaar",
|
||||
"proceduresIndexed": "{{count}} procedures geïndexeerd",
|
||||
@@ -16,6 +20,7 @@
|
||||
"ask": "Vraag stellen",
|
||||
"asking": "Bezig met vragen…",
|
||||
"askFailed": "Kon de kennisdienst niet bereiken.",
|
||||
"feedbackFailed": "Kon feedback niet opslaan.",
|
||||
"suggestedLabel": "Probeer:",
|
||||
"suggestedQuestions": [
|
||||
"Wat moet ik doen wanneer een voertuig terugkomt met schade?",
|
||||
@@ -38,5 +43,12 @@
|
||||
"unavailable": "Kennisdienst niet beschikbaar"
|
||||
},
|
||||
"unavailableBody": "De kennisdienst is momenteel niet bereikbaar. Operationele functies zijn hier niet door beïnvloed — probeer later opnieuw.",
|
||||
"sourceVersion": "v{{version}}"
|
||||
"sourceVersion": "v{{version}}",
|
||||
"answeredIn": "Beantwoord in {{seconds}} s",
|
||||
"sourceTechnicalVersion": "Technische bronversie beschikbaar in audit",
|
||||
"feedbackLabel": "Feedback op antwoord",
|
||||
"feedbackPrompt": "Was dit antwoord bruikbaar?",
|
||||
"feedbackThanks": "Feedback opgeslagen",
|
||||
"feedbackHelpful": "Ja",
|
||||
"feedbackNotHelpful": "Nee"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ export function Automation() {
|
||||
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
|
||||
const [retrying, setRetrying] = useState<string | null>(null);
|
||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||
const [knowledgeSettled, setKnowledgeSettled] = useState(false);
|
||||
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
||||
const [integrationSettled, setIntegrationSettled] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setRuns(null);
|
||||
@@ -49,14 +51,21 @@ export function Automation() {
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
|
||||
setKnowledgeSettled(false);
|
||||
api
|
||||
.get<KnowledgeHealth>("/api/v1/knowledge/status")
|
||||
.then(setKnowledge)
|
||||
.catch(() => setKnowledge(null))
|
||||
.finally(() => setKnowledgeSettled(true));
|
||||
}, []);
|
||||
|
||||
const loadIntegrationStatus = useCallback(() => {
|
||||
setIntegrationSettled(false);
|
||||
api
|
||||
.get<IntegrationStatus>("/api/v1/integrations/status")
|
||||
.then(setIntegrationStatus)
|
||||
.catch(() => setIntegrationStatus(null));
|
||||
.catch(() => setIntegrationStatus(null))
|
||||
.finally(() => setIntegrationSettled(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,7 +176,9 @@ export function Automation() {
|
||||
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
|
||||
<h2>{t("cards.n8nTitle")}</h2>
|
||||
<p>
|
||||
{integrationStatus
|
||||
{!integrationSettled
|
||||
? t("cards.checking")
|
||||
: integrationStatus
|
||||
? t("cards.n8nSummary", {
|
||||
succeeded: integrationStatus.n8n.succeeded,
|
||||
failed: integrationStatus.n8n.unexpected_failed,
|
||||
@@ -188,8 +199,8 @@ export function Automation() {
|
||||
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
|
||||
label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined}
|
||||
status={!integrationSettled ? "loading" : (meta?.statusClass ?? (runs?.[0]?.status ?? "unavailable"))}
|
||||
label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@@ -200,7 +211,9 @@ export function Automation() {
|
||||
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
|
||||
<h2>{t("cards.knowledgeTitle")}</h2>
|
||||
<p>
|
||||
{knowledge
|
||||
{!knowledgeSettled
|
||||
? t("cards.checking")
|
||||
: knowledge
|
||||
? knowledge.document_count === null
|
||||
? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
|
||||
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
|
||||
@@ -211,9 +224,11 @@ export function Automation() {
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={knowledge?.available ? "available" : "unavailable"}
|
||||
status={!knowledgeSettled ? "loading" : knowledge?.available ? "available" : "unavailable"}
|
||||
label={
|
||||
knowledge?.available
|
||||
!knowledgeSettled
|
||||
? t("statusLabels.checking")
|
||||
: knowledge?.available
|
||||
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
|
||||
: t("statusLabels.unavailable")
|
||||
}
|
||||
@@ -226,6 +241,7 @@ export function Automation() {
|
||||
<h2>{t("cards.mcpTitle")}</h2>
|
||||
<p>
|
||||
{(() => {
|
||||
if (!integrationSettled) return t("cards.checking");
|
||||
const hub = integrationStatus?.mcp_hub;
|
||||
if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
|
||||
if (hub.total_calls > 0) {
|
||||
@@ -241,7 +257,12 @@ export function Automation() {
|
||||
<div className="integration-badge-stack">
|
||||
{(() => {
|
||||
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
|
||||
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={!integrationSettled ? "loading" : (meta?.statusClass ?? "unavailable")}
|
||||
label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
|
||||
<StatusBadge
|
||||
@@ -255,10 +276,16 @@ export function Automation() {
|
||||
|
||||
<SectionHeading
|
||||
title={t("workflows.title")}
|
||||
description={t("workflows.description", {
|
||||
known: integrationStatus?.n8n.known_workflow_count ?? 0,
|
||||
expected: integrationStatus?.n8n.expected_workflow_count ?? 4,
|
||||
})}
|
||||
description={
|
||||
!integrationSettled
|
||||
? t("workflows.loadingEvidence")
|
||||
: integrationStatus
|
||||
? t("workflows.description", {
|
||||
known: integrationStatus.n8n.known_workflow_count,
|
||||
expected: integrationStatus.n8n.expected_workflow_count,
|
||||
})
|
||||
: t("workflows.unavailable")
|
||||
}
|
||||
/>
|
||||
|
||||
{integrationStatus && (
|
||||
@@ -288,7 +315,10 @@ export function Automation() {
|
||||
: { status: "no_events", label: t("workflows.noEvidence") };
|
||||
return (
|
||||
<tr key={w.name}>
|
||||
<td data-label={t("workflows.columns.name")}>{displayName}</td>
|
||||
<td data-label={t("workflows.columns.name")}>
|
||||
{displayName}
|
||||
{slug && <span className="table-subtext">{t(`workflows.expectations.${slug}`)}</span>}
|
||||
</td>
|
||||
<td data-label={t("workflows.columns.status")}>
|
||||
<StatusBadge status={badge.status} label={badge.label} />
|
||||
</td>
|
||||
|
||||
@@ -10,11 +10,19 @@ import { PRODUCT_NAME } from "../product";
|
||||
interface Exchange {
|
||||
question: string;
|
||||
answer: GroundedAnswer;
|
||||
durationMs: number;
|
||||
feedback: boolean | null;
|
||||
feedbackSaving: boolean;
|
||||
}
|
||||
|
||||
function isOpaqueVersion(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value);
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const { t, i18n } = useTranslation(["knowledge", "errors"]);
|
||||
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
|
||||
const [statusSettled, setStatusSettled] = useState(false);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
@@ -23,21 +31,30 @@ export function Knowledge() {
|
||||
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
|
||||
|
||||
useEffect(() => {
|
||||
setStatusSettled(false);
|
||||
api
|
||||
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus(null));
|
||||
.catch(() => setStatus(null))
|
||||
.finally(() => setStatusSettled(true));
|
||||
}, [language]);
|
||||
|
||||
async function ask(questionText: string) {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
|
||||
question: questionText,
|
||||
language,
|
||||
});
|
||||
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
|
||||
setExchanges((previous) => [{
|
||||
question: questionText,
|
||||
answer,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
feedback: null,
|
||||
feedbackSaving: false,
|
||||
}, ...previous]);
|
||||
setQuestion("");
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "askFailed"));
|
||||
@@ -46,13 +63,42 @@ export function Knowledge() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!question.trim()) return;
|
||||
await ask(question);
|
||||
}
|
||||
|
||||
const providerLabel = status?.provider === "ragcore" ? t("providerRagcore") : t("providerDemo");
|
||||
async function sendFeedback(correlationId: string, helpful: boolean) {
|
||||
setExchanges((previous) => previous.map((exchange) =>
|
||||
exchange.answer.correlation_id === correlationId
|
||||
? { ...exchange, feedbackSaving: true }
|
||||
: exchange,
|
||||
));
|
||||
try {
|
||||
await api.post("/api/v1/knowledge/feedback", { correlation_id: correlationId, helpful });
|
||||
setExchanges((previous) => previous.map((exchange) =>
|
||||
exchange.answer.correlation_id === correlationId
|
||||
? { ...exchange, feedback: helpful, feedbackSaving: false }
|
||||
: exchange,
|
||||
));
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "feedbackFailed"));
|
||||
setExchanges((previous) => previous.map((exchange) =>
|
||||
exchange.answer.correlation_id === correlationId
|
||||
? { ...exchange, feedbackSaving: false }
|
||||
: exchange,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const providerLabel = !statusSettled
|
||||
? t("providerChecking")
|
||||
: status?.provider === "ragcore"
|
||||
? t("providerRagcore")
|
||||
: status?.provider === "demo"
|
||||
? t("providerDemo")
|
||||
: t("providerUnavailable");
|
||||
const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[];
|
||||
|
||||
return (
|
||||
@@ -62,59 +108,69 @@ export function Knowledge() {
|
||||
title={t("title")}
|
||||
description={t("description", { provider: providerLabel.toLowerCase() })}
|
||||
/>
|
||||
{status && (
|
||||
{!statusSettled && (
|
||||
<div className="knowledge-status is-loading" role="status">
|
||||
<span className="health-orb" />
|
||||
<div><strong>{t("statusChecking")}</strong><span>{t("statusCheckingDetail")}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{statusSettled && status && (
|
||||
<div className="knowledge-status">
|
||||
<span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} />
|
||||
<div>
|
||||
<strong>{providerLabel}</strong>
|
||||
<span>
|
||||
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
|
||||
{status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })}
|
||||
{status.document_count === null
|
||||
? t("proceduresIndexUnknown")
|
||||
: t("proceduresIndexed", { count: status.document_count })}
|
||||
</span>
|
||||
</div>
|
||||
<small>{status.collection}</small>
|
||||
</div>
|
||||
)}
|
||||
{status?.provider !== "ragcore" && (
|
||||
<p className="knowledge-provider-note">
|
||||
<Icon name="shield" /> {t("providerNote")}
|
||||
</p>
|
||||
{statusSettled && !status && (
|
||||
<div className="knowledge-status" role="status">
|
||||
<span className="health-orb is-down" />
|
||||
<div><strong>{t("statusUnavailable")}</strong><span>{t("unavailableBody")}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{statusSettled && status?.provider === "demo" && (
|
||||
<p className="knowledge-provider-note"><Icon name="shield" /> {t("providerNote")}</p>
|
||||
)}
|
||||
|
||||
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
|
||||
<div className="ask-heading">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div>
|
||||
<h2 id="ask-heading">{t("askHeading")}</h2>
|
||||
<p>{t("askSubheading")}</p>
|
||||
</div>
|
||||
<div><h2 id="ask-heading">{t("askHeading")}</h2><p>{t("askSubheading")}</p></div>
|
||||
</div>
|
||||
<label htmlFor="knowledge-question" className="visually-hidden">
|
||||
{t("questionLabel")}
|
||||
</label>
|
||||
<label htmlFor="knowledge-question" className="visually-hidden">{t("questionLabel")}</label>
|
||||
<div className="knowledge-input-row">
|
||||
<input
|
||||
id="knowledge-question"
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder={t("questionPlaceholder")}
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
required
|
||||
/>
|
||||
<button className="button button-primary" type="submit" disabled={submitting}>
|
||||
{submitting ? t("asking") : t("ask")}
|
||||
{!submitting && <Icon name="chevron" />}
|
||||
{submitting ? t("asking") : t("ask")}{!submitting && <Icon name="chevron" />}
|
||||
</button>
|
||||
</div>
|
||||
<ApiErrorNotice error={error} />
|
||||
<div className="knowledge-suggestions">
|
||||
<span>{t("suggestedLabel")}</span>
|
||||
{suggestedQuestions.map((q) => (
|
||||
<button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}>
|
||||
{q}
|
||||
</button>
|
||||
{suggestedQuestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="suggestion-chip"
|
||||
onClick={() => ask(suggestion)}
|
||||
disabled={submitting}
|
||||
>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
@@ -134,34 +190,57 @@ export function Knowledge() {
|
||||
)}
|
||||
|
||||
<ul className="exchange-list">
|
||||
{exchanges.map((exchange, index) => (
|
||||
<li key={index} className="panel exchange">
|
||||
<p className="exchange-question">
|
||||
<strong>{t("questionLabelExchange")}</strong> {exchange.question}
|
||||
</p>
|
||||
{exchanges.map((exchange) => (
|
||||
<li key={exchange.answer.correlation_id} className="panel exchange">
|
||||
<p className="exchange-question"><strong>{t("questionLabelExchange")}</strong> {exchange.question}</p>
|
||||
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
|
||||
{t(`evidenceStates.${exchange.answer.evidence_state}`)}
|
||||
</p>
|
||||
|
||||
{exchange.answer.evidence_state === "unavailable" ? (
|
||||
<p>{t("unavailableBody")}</p>
|
||||
) : (
|
||||
<p>{exchange.answer.answer}</p>
|
||||
)}
|
||||
<p className="exchange-timing">
|
||||
{t("answeredIn", { seconds: (exchange.durationMs / 1000).toFixed(1) })}
|
||||
</p>
|
||||
{exchange.answer.evidence_state === "unavailable"
|
||||
? <p>{t("unavailableBody")}</p>
|
||||
: <p>{exchange.answer.answer}</p>}
|
||||
|
||||
{exchange.answer.sources.length > 0 && (
|
||||
<ul className="source-cards">
|
||||
{exchange.answer.sources.map((source) => (
|
||||
<li key={`${source.document_id}-${source.section}`} className="source-card">
|
||||
<p className="source-title">
|
||||
{source.title} <span className="source-version">{t("sourceVersion", { version: source.version })}</span>
|
||||
</p>
|
||||
<p className="source-section">{source.section}</p>
|
||||
<p className="source-excerpt">{source.excerpt}</p>
|
||||
<ul className="source-cards source-cards-collapsed">
|
||||
{exchange.answer.sources.map((source, sourceIndex) => (
|
||||
<li key={`${source.title}-${source.section}-${source.excerpt}`} className="source-card">
|
||||
<details open={sourceIndex === 0}>
|
||||
<summary>
|
||||
<span className="source-number">{sourceIndex + 1}</span>
|
||||
<span className="source-title">{source.title}</span>
|
||||
{source.section && <span className="source-section">{source.section}</span>}
|
||||
</summary>
|
||||
<p className="source-excerpt">{source.excerpt}</p>
|
||||
<p className="source-version">
|
||||
{isOpaqueVersion(source.version)
|
||||
? t("sourceTechnicalVersion")
|
||||
: t("sourceVersion", { version: source.version })}
|
||||
</p>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="knowledge-feedback" aria-label={t("feedbackLabel")}>
|
||||
<span>{exchange.feedback === null ? t("feedbackPrompt") : t("feedbackThanks")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={exchange.feedback === true ? "is-selected" : ""}
|
||||
disabled={exchange.feedbackSaving}
|
||||
aria-pressed={exchange.feedback === true}
|
||||
onClick={() => sendFeedback(exchange.answer.correlation_id, true)}
|
||||
>{t("feedbackHelpful")}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={exchange.feedback === false ? "is-selected" : ""}
|
||||
disabled={exchange.feedbackSaving}
|
||||
aria-pressed={exchange.feedback === false}
|
||||
onClick={() => sendFeedback(exchange.answer.correlation_id, false)}
|
||||
>{t("feedbackNotHelpful")}</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -382,11 +382,29 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); }
|
||||
.knowledge-status.is-loading .health-orb { background: var(--line-strong); animation: pulse-soft 1.2s ease-in-out infinite; }
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; }
|
||||
.knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; }
|
||||
.retrieval-flow { display: flex; align-items: center; gap: 8px; margin-top: 24px; }.retrieval-flow span { padding: 6px 9px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); font-size: .61rem; font-weight: 700; }.retrieval-flow i { width: 24px; border-top: 1px dashed var(--teal); }
|
||||
.exchange-list, .source-cards { list-style: none; margin: 0; padding: 0; }.exchange-list { display: grid; gap: 14px; }.exchange-question { display: grid; gap: 5px; margin: 0 0 10px; font-size: .85rem; }.exchange-question strong { color: var(--muted); font-size: .6rem; text-transform: uppercase; letter-spacing: .08em; }.evidence-state { width: max-content; padding: 4px 8px; border-radius: 999px; font-size: .62rem; font-weight: 700; }.evidence-grounded { color: var(--success); background: var(--success-pale); }.evidence-insufficient { color: var(--warning); background: var(--warning-pale); }.evidence-unavailable { color: var(--critical); background: var(--critical-pale); }
|
||||
.source-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 9px; margin-top: 13px; }.source-card { padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); }.source-title { margin: 0; font-size: .73rem; font-weight: 700; }.source-version { color: var(--muted); font-size: .62rem; }.source-section { margin: 5px 0; color: var(--teal-dark); font-size: .65rem; font-weight: 700; }.source-excerpt { margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; }
|
||||
.source-cards-collapsed { grid-template-columns: 1fr; }
|
||||
.source-cards-collapsed .source-card { padding: 0; border-radius: var(--radius-sm); }
|
||||
.source-cards-collapsed summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px 13px; cursor: pointer; }
|
||||
.source-cards-collapsed details[open] summary { border-bottom: 1px solid var(--line); }
|
||||
.source-cards-collapsed .source-number { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; color: var(--teal-dark); background: var(--teal-pale); font-size: .64rem; font-weight: 800; }
|
||||
.source-cards-collapsed .source-section { margin: 0; text-align: right; }
|
||||
.source-cards-collapsed .source-excerpt { padding: 13px 13px 5px; }
|
||||
.source-cards-collapsed .source-version { margin: 0; padding: 0 13px 13px; }
|
||||
.exchange-timing { margin: -2px 0 12px; color: var(--muted); font-size: .65rem; }
|
||||
.knowledge-feedback { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; }
|
||||
.knowledge-feedback button { min-height: 34px; padding: 5px 10px; }
|
||||
.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); }
|
||||
|
||||
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
|
||||
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
|
||||
|
||||
Reference in New Issue
Block a user