feat(rag): steering notes — tune how Brain answers, stored in Postgres and injected into every system prompt

This commit is contained in:
2026-08-22 16:44:42 -04:00
parent 19df7df99d
commit fc0d9a2d5c
19 changed files with 1589 additions and 34 deletions
+212 -1
View File
@@ -28,6 +28,16 @@
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
* state.
*
* Steering notes (phase 15) let the owner tune how Brain answers: a
* "Tune" button under every completed brain bubble (deflected included)
* opens an inline form → POST /api/steering → the note is stored in
* Postgres and injected into the system prompt of every subsequent turn
* (the <tuning> section). Notes are listed newest-first in the header
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
* is always rendered with textContent (XSS-safe), save/delete are
* announced through a polite live region (#steering-announcer), and the
* panel + count badge update on every change.
*
* All DOM ids match frontend/index.html.
*/
@@ -90,6 +100,203 @@ export function documentUrl(source, path, back = "/") {
return url;
}
/* ---------- steering notes (phase 15) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. UI contract: Tune button → inline form → save →
* confirmation (or inline error, form kept); the header panel lists the
* notes (newest first) with per-note delete.
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
const TUNE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
let tuneSeq = 0; // unique ids for one open tune form's inputs
function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
sources' .msg-meta row when it exists (role=list → the button joins as
a listitem so ARIA stays valid); otherwise creates a plain meta row. */
function appendTuneButton(wrap) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".tune-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tune-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = TUNE_ICON + "<span>Tune</span>";
btn.addEventListener("click", () => openTuneForm(wrap, btn));
meta.appendChild(btn);
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
+ Save / Cancel. Success replaces the form with the .tune-saved status
(role=status); failure keeps the form and shows an inline error
(role=alert) — the note is never lost on a failed save. */
function openTuneForm(wrap, toggleBtn) {
document.querySelectorAll(".tune-form").forEach((f) => f.remove()); // one at a time
const body = wrap.querySelector(".msg-body");
if (!body) return;
tuneSeq += 1;
const inputId = `tune-input-${tuneSeq}`;
const form = document.createElement("form");
form.className = "tune-form";
form.noValidate = true;
form.innerHTML =
`<label for="${inputId}">Tuning note — how should Brain answer from now on?</label>` +
`<textarea id="${inputId}" name="note" rows="2" maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
const actions = document.createElement("div");
actions.className = "tune-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
form.appendChild(actions);
const status = document.createElement("p");
status.className = "tune-error";
status.setAttribute("role", "alert");
status.hidden = true;
form.appendChild(status);
form.addEventListener("submit", async (e) => {
e.preventDefault();
saveBtn.disabled = true;
status.hidden = true;
try {
const r = await fetch("/api/steering", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: form.querySelector("textarea").value }),
});
if (!r.ok) {
let detail = "Could not save the note — try again.";
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
detail = String(data.detail[0].msg);
} else if (typeof data.detail === "string" && data.detail) {
detail = data.detail;
}
} catch { /* non-JSON error body */ }
status.textContent = detail;
status.hidden = false;
saveBtn.disabled = false;
return; // form kept on failure — the instruction survives
}
const saved = document.createElement("p");
saved.className = "tune-saved";
saved.setAttribute("role", "status");
saved.textContent = "Saved — future answers will follow this.";
form.replaceWith(saved);
announceSteering("Tuning note saved. Future answers will follow it.");
await loadSteering(); // panel + count badge update
} catch {
status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false;
saveBtn.disabled = false;
}
});
cancelBtn.addEventListener("click", () => {
form.remove();
toggleBtn.focus();
});
body.appendChild(form);
form.querySelector("textarea").focus();
}
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
empty text, and the header count badge. */
async function loadSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch { /* API unreachable: keep the last rendered list */ }
renderSteeringPanel(notes);
return notes;
}
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await loadSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await loadSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
});
}
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
* Inline SVG as string constants so the message renderer and the typing
* indicator share exactly the same marks. currentColor lets the CSS theme
@@ -426,6 +633,7 @@ function renderStoredMessage(m) {
appendMaybeTry(wrap, m.suggestions);
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
}
/* On load: re-render the stored conversation (markdown, source chips,
@@ -545,6 +753,7 @@ async function handleSend(e) {
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata).
rememberBrainTurn(acc, {
@@ -558,7 +767,8 @@ async function handleSend(e) {
});
if (!aborted && !wrap) {
const fallback = "Hmm — that came back empty. Ask me again?";
addMessage("brain", fallback);
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
}
} catch (err) {
@@ -592,3 +802,4 @@ composer.addEventListener("submit", handleSend);
restoreConversation(); // phase 14: the conversation comes back as left
loadSuggestions();
loadHealth();
loadSteering(); // phase 15: tuning notes (panel + count badge)