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
+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>