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
+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>
);
}