M17: ground knowledge and integration evidence

This commit is contained in:
NuklearRabbit
2026-08-10 12:27:17 +02:00
parent 686795a452
commit 8030753dbc
15 changed files with 480 additions and 74 deletions
+7 -3
View File
@@ -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."
+13 -1
View File
@@ -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 linstant",
"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 lenregistrement sécurisé dun retour de véhicule.",
"scheduledScan": "Planifié ; sexécute normalement chaque heure.",
"ragcoreSync": "Planifié ; sexécute normalement chaque jour.",
"errorHandler": "Sexécute uniquement lorsquun 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é."
+13 -1
View File
@@ -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 denregistrer 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 laudit",
"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."
+13 -1
View File
@@ -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"
}
+44 -14
View File
@@ -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>
+123 -44
View File
@@ -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>
+18
View File
@@ -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; }