feat(rag): stream grounded RAG answers over SSE with source citations
Phase 03 (Story: Chat RAG Answer — happy path):
- app/rag/retriever.py: top-k cosine search + parent-doc selection with
per-doc dedupe and BOR_MAX_CONTEXT_CHARS cap ([…truncated…] marker)
- app/rag/prompts.py: locked persona + HIGH/DEFLECT prompt builders
- app/rag/llm.py: LLMError + chat_stream (turbo, temp 0.4, max 700, stream)
- app/api/chat.py: POST /api/chat SSE — delta* then done{deflected,
sources, suggestions}; query_log row + PLAN §9 per-turn log line;
structured error event on mid-stream failure, JSON 503 when DB down
- frontend: SSE reader, live bubble streaming, source chips -> /sources.html,
red role=alert banner, Send button state that always recovers
- fix(scaffold): [hidden] { display: none !important } — .kb-banner's
display:flex was overriding the hidden attribute (banner always visible)
- tests: unit (retriever/prompts/sse/llm) + integration (real Postgres RAG
turn, query_log, error + 503 paths, mid-turn failures) + Playwright story
suite (grounded answer, log row, raw SSE shape); smoke placeholder test
replaced with the real never-stale-button contract
This commit is contained in:
+114
-12
@@ -1,8 +1,11 @@
|
||||
/* Brain of Reese — chat shell.
|
||||
*
|
||||
* Scaffolding-stage behavior: renders suggestions, shows KB health, and
|
||||
* echoes a friendly placeholder answer. The real RAG streaming chat is
|
||||
* implemented in the chat-rag phase (see .agent/user_stories/).
|
||||
* Renders suggestions, shows KB health, and runs chat turns against
|
||||
* POST /api/chat (SSE, PLAN §4): deltas render live into the Brain bubble,
|
||||
* the done event appends source chips, errors surface as a red banner.
|
||||
* The full feedback state machine lands with the loading-feedback story;
|
||||
* this keeps the "never stale" contract: the button is busy for the whole
|
||||
* turn and is always re-enabled at the end.
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -148,6 +151,70 @@ function autoGrow() {
|
||||
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
|
||||
}
|
||||
|
||||
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
|
||||
|
||||
/* Parse an SSE response body into JSON events. */
|
||||
async function readSSE(response, onEvent) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep;
|
||||
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||
const frame = buf.slice(0, sep).trim();
|
||||
buf = buf.slice(sep + 2);
|
||||
if (!frame.startsWith("data:")) continue;
|
||||
const payload = frame.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
onEvent(JSON.parse(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Source chips (mono, source/path) under a Brain bubble. */
|
||||
function appendSources(wrap, sources) {
|
||||
if (!sources || !sources.length) return;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
meta.setAttribute("role", "list");
|
||||
meta.setAttribute("aria-label", "Sources");
|
||||
for (const s of sources) {
|
||||
const label = `${s.source}/${s.path}`;
|
||||
const chip = document.createElement("a");
|
||||
chip.className = "source-chip";
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.href = "/sources.html";
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
meta.appendChild(chip);
|
||||
}
|
||||
body.appendChild(meta);
|
||||
// Accessible full path whenever the pill visually truncates.
|
||||
for (const chip of meta.children) {
|
||||
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorBanner(detail) {
|
||||
banner.hidden = false;
|
||||
banner.classList.add("is-error");
|
||||
banner.setAttribute("role", "alert");
|
||||
bannerText.textContent = `${detail} Try your question again — I'm ready.`;
|
||||
}
|
||||
|
||||
function clearErrorBanner() {
|
||||
if (banner.classList.contains("is-error")) {
|
||||
banner.classList.remove("is-error");
|
||||
banner.setAttribute("role", "status");
|
||||
bannerText.textContent = "";
|
||||
banner.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
@@ -156,21 +223,56 @@ async function handleSend(e) {
|
||||
addMessage("user", renderMarkdown(text));
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
clearErrorBanner();
|
||||
setBusy(true);
|
||||
addTyping();
|
||||
|
||||
let wrap = null;
|
||||
let acc = "";
|
||||
let res = null;
|
||||
try {
|
||||
// TODO(chat-rag phase): replace with POST /api/chat (SSE streaming).
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
const reply =
|
||||
"I'm still getting my neurons wired up — the real me ships in the " +
|
||||
"next phase! Keep the questions coming, you're on a roll. 🚀";
|
||||
res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
let detail = `Brain's API answered with HTTP ${res.status}.`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body.detail) detail = body.detail;
|
||||
} catch { /* non-JSON error body */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
await readSSE(res, (ev) => {
|
||||
if (ev.type === "delta") {
|
||||
acc += ev.text || "";
|
||||
if (!wrap) {
|
||||
removeTyping();
|
||||
wrap = addMessage("brain", "");
|
||||
}
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
} else if (ev.type === "done") {
|
||||
if (!wrap) {
|
||||
removeTyping();
|
||||
wrap = addMessage("brain", "…");
|
||||
}
|
||||
if (ev.deflected) wrap.classList.add("is-deflected");
|
||||
appendSources(wrap, ev.sources);
|
||||
} else if (ev.type === "error") {
|
||||
throw new Error(ev.detail || "Something went wrong on my side.");
|
||||
}
|
||||
});
|
||||
if (!wrap) {
|
||||
removeTyping();
|
||||
addMessage("brain", "Hmm — that came back empty. Ask me again?");
|
||||
}
|
||||
} catch (err) {
|
||||
removeTyping();
|
||||
addMessage("brain", renderMarkdown(reply));
|
||||
} catch {
|
||||
removeTyping();
|
||||
addMessage("brain", "Something went wrong on my side — please try again in a moment.");
|
||||
showErrorBanner(err.message || "Something went wrong on my side.");
|
||||
} finally {
|
||||
try { res?.body?.cancel(); } catch { /* stream already closed */ }
|
||||
setBusy(false);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ body {
|
||||
}
|
||||
|
||||
/* ---------- Accessibility helpers ---------- */
|
||||
/* The `hidden` attribute must always win — some components set an explicit
|
||||
`display` (e.g. .kb-banner { display: flex }) which would otherwise override
|
||||
the UA stylesheet's `[hidden] { display: none }` and leave the element visible. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
|
||||
Reference in New Issue
Block a user