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:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user