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)
+220
View File
@@ -241,6 +241,48 @@ body::after {
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
(not removed) below 640px so the accessible name keeps the word.
≥44px touch target at every width. */
.steering-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.steering-toggle:hover, .steering-toggle[aria-expanded="true"] {
background: var(--brand-soft);
color: var(--brand-ink);
}
.steering-toggle svg { width: 16px; height: 16px; display: block; }
.steering-count {
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 700;
min-width: 1.35rem;
text-align: center;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand-ink);
}
.steering-toggle[aria-expanded="true"] .steering-count {
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
}
/* ---------- Main frame ---------- */
.app-main {
flex: 1;
@@ -381,6 +423,170 @@ body::after {
max-width: 100%;
}
/* ---------- Steering notes (phase 15) ---------- */
/* "Tune" button in the meta row of every completed brain bubble: ghost
pill, ≥44px, right-aligned after the source chips. ink-soft on surface
≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. */
.tune-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
margin-left: auto;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.tune-btn svg { width: 14px; height: 14px; display: block; }
.tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
.tune-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
}
.tune-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-soft);
}
.tune-form textarea {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: #0d1120;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.tune-form-actions { display: flex; gap: 0.5rem; }
.tune-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.tune-save:hover:not(:disabled) { background: #7d88f5; }
.tune-save:disabled { opacity: 0.6; cursor: wait; }
.tune-cancel {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tune-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Save confirmation (role=status): ok pair ≈10.6:1. */
.tune-saved {
margin: 0.2rem 0 0 0.25rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid rgb(110 231 168 / 0.35);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Inline save failure (role=alert): err pair ≈9.1:1 — form is kept. */
.tune-error {
margin: 0 0 0 0.25rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Header panel above the messages: notes newest-first, per-note delete,
designed empty state. */
.steering-panel {
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1.1rem 1rem;
}
.steering-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; }
.steering-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
.steering-panel-sub { margin: 0; font-size: 0.82rem; color: var(--ink-soft); }
.steering-list {
list-style: none;
margin: 0.65rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.steering-note {
display: flex;
align-items: stretch;
gap: 0.6rem;
background: #0d1120;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
}
.steering-note-text {
color: var(--ink);
font-size: 0.9rem;
line-height: 1.45;
/* Notes may be multi-line instructions — keep line breaks as typed. */
white-space: pre-wrap;
overflow-wrap: anywhere;
flex: 1;
min-width: 0;
}
.steering-delete {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
}
.steering-delete svg { width: 16px; height: 16px; display: block; }
.steering-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
/* typing indicator */
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
.typing span {
@@ -799,6 +1005,20 @@ body::after {
.new-chat-btn { padding: 0.4rem 0.55rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
.steering-toggle { padding: 0.4rem 0.55rem; }
/* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */
.steering-label {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.tune-btn { min-height: 44px; }
.msg-body { max-width: 92%; }
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
.empty-state-title { font-size: 1.25rem; }
+20
View File
@@ -21,6 +21,14 @@
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
<a href="/sources.html" class="nav-link">Sources</a>
</nav>
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
into every system prompt) — chat page only. -->
<button type="button" class="steering-toggle" id="steering-toggle"
aria-expanded="false" aria-controls="steering-panel">
<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>
<span class="steering-label">Tuning</span>
<span class="steering-count" id="steering-count">0</span>
</button>
<!-- Phase 14: reset the local (localStorage) conversation — chat page only. -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
@@ -36,6 +44,18 @@
<span id="kb-banner-text"></span>
</div>
<!-- Phase 15: tuning-notes panel (stored notes, newest first). -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
<div class="empty-state" id="empty-state">
<div class="empty-state-glyph" aria-hidden="true">