M5: implement RAGcore knowledge integration

KnowledgeProvider protocol with a deterministic TF-IDF-weighted extractive demo provider (never generative, always cites real excerpts) and a RAGcore HTTP adapter that degrades cleanly to unavailable. Knowledge nav + chat-style Q&A UI with source cards and honest grounded/insufficient/unavailable states. 57 backend tests passing, ruff clean. Fixed a real relevance bug (generic terms like "vehicle" crowding out distinctive matches) via IDF weighting, found by testing the actual S6 scenario. Verified end-to-end in the browser: grounded damage question cites both expected procedures; unrelated question honestly returns insufficient evidence with no fabrication.
This commit is contained in:
NuklearRabbit
2026-08-01 22:57:06 +02:00
parent 59d663a43a
commit b511ba2dbc
14 changed files with 710 additions and 3 deletions
+2
View File
@@ -11,6 +11,7 @@ import { BookingDetail } from "./pages/BookingDetail";
import { DataQuality } from "./pages/DataQuality";
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation";
import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit";
export function App() {
@@ -33,6 +34,7 @@ export function App() {
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
+26
View File
@@ -167,6 +167,32 @@ export interface MergeCustomersResult {
rewired_bookings: number;
}
export interface SourceCard {
document_id: string;
title: string;
version: string;
section: string;
excerpt: string;
}
export interface GroundedAnswer {
answer: string;
evidence_state: "grounded" | "insufficient" | "unavailable";
sources: SourceCard[];
provider: string;
correlation_id: string;
}
export interface KnowledgeHealth {
provider: string;
available: boolean;
detail: string;
tenant: string;
workspace: string;
collection: string;
document_count: number;
}
export interface AuditEvent {
id: string;
actor_type: string;
+1
View File
@@ -6,6 +6,7 @@ const NAV_ITEMS = [
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/data-quality", label: "Data Quality" },
{ to: "/knowledge", label: "Knowledge" },
{ to: "/automation", label: "Automation" },
{ to: "/audit", label: "Audit" },
];
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, ApiError } from "../api/client";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
interface Exchange {
question: string;
answer: GroundedAnswer;
}
const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
grounded: "Grounded in cited procedures",
insufficient: "Insufficient evidence",
unavailable: "Knowledge service unavailable",
};
export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
useEffect(() => {
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.then(setStatus)
.catch(() => setStatus(null));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!question.trim()) return;
setError(null);
setSubmitting(true);
try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question });
setExchanges((prev) => [{ question, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service.");
} finally {
setSubmitting(false);
}
}
return (
<div className="page">
<h1>Knowledge</h1>
{status && (
<p className="knowledge-status">
Provider: <strong>{status.provider}</strong> ·{" "}
{status.available ? "available" : "unavailable"} · {status.document_count} procedures
indexed
</p>
)}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<h2 id="ask-heading">Ask a procedure question</h2>
<label htmlFor="knowledge-question" className="visually-hidden">
Question
</label>
<div className="knowledge-input-row">
<input
id="knowledge-question"
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. What must I do when a vehicle returns with damage?"
minLength={3}
maxLength={1000}
required
/>
<button type="submit" disabled={submitting}>
{submitting ? "Asking…" : "Ask"}
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
</form>
{exchanges.length === 0 && !error && (
<p>Ask a question about one of the ten operational procedures to see cited sources.</p>
)}
<ul className="exchange-list">
{exchanges.map((exchange, index) => (
<li key={index} className="panel exchange">
<p className="exchange-question">
<strong>Q:</strong> {exchange.question}
</p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{EVIDENCE_LABEL[exchange.answer.evidence_state]}
</p>
{exchange.answer.evidence_state === "unavailable" ? (
<p>
The knowledge service is currently unreachable. Operational features are
unaffected try again later.
</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">v{source.version}</span>
</p>
<p className="source-section">{source.section}</p>
<p className="source-excerpt">{source.excerpt}</p>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
+27
View File
@@ -226,6 +226,33 @@ a { color: #1f5c8f; }
}
.data-table td button:disabled { opacity: 0.6; cursor: not-allowed; }
.knowledge-status { color: #607084; font-size: 0.9rem; margin-bottom: 16px; }
.knowledge-form { margin-bottom: 20px; }
.knowledge-input-row { display: flex; gap: 10px; flex-wrap: wrap; }
.knowledge-input-row input {
flex: 1; min-width: 240px; padding: 10px 12px; border: 1px solid #cfd8e2;
border-radius: 8px; font-size: 0.95rem;
}
.knowledge-input-row button {
padding: 10px 20px; border-radius: 8px; border: none;
background: #14324f; color: white; font-weight: 700; cursor: pointer;
}
.knowledge-input-row button:disabled { opacity: 0.6; cursor: not-allowed; }
.exchange-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 16px; }
.exchange-question { font-size: 1rem; margin: 0 0 6px; }
.evidence-state { display: inline-block; margin: 0 0 10px; padding: 3px 10px; border-radius: 999px; font-weight: 700; font-size: 0.8rem; }
.evidence-grounded { background: #e6f5ec; color: #1f6d3d; }
.evidence-insufficient { background: #fdf1de; color: #8a5a10; }
.evidence-unavailable { background: #fbe6e6; color: #8f2323; }
.source-cards { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.source-card { background: #f6f8fb; border: 1px solid #dce3eb; border-radius: 10px; padding: 12px; }
.source-title { margin: 0; font-weight: 700; }
.source-version { color: #607084; font-weight: 400; font-size: 0.85rem; }
.source-section { margin: 2px 0 6px; color: #375065; font-size: 0.85rem; font-weight: 600; }
.source-excerpt { margin: 0; font-size: 0.88rem; color: #47566b; }
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
.user-badge { margin-left: 0; }