diff --git a/.env.example b/.env.example index 61c9cc6..0b35777 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,7 @@ BOR_TOP_N_DOCS=2 BOR_RELEVANCE_THRESHOLD=0.62 # answer when best cosine >= this OR an FTS hit; else honest deflection BOR_MAX_CONTEXT_CHARS=24000 # cap on total document text sent to the LLM BOR_MAX_OUTPUT_TOKENS=32768 # max answer length in tokens (answers must not be cut off) +BOR_STEERING_MAX_CHARS=8000 # char budget for the (steering notes) prompt section BOR_CHUNK_TARGET_CHARS=2000 BOR_CHUNK_OVERLAP_CHARS=200 BOR_EMBED_BATCH_SIZE=16 diff --git a/README.md b/README.md index 14bfe5f..d0f223d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,34 @@ uv run uvicorn app.main:app --reload - **Sources** (`/sources.html`) — the indexed document list; the *Path* column links each document to the viewer in a new tab. +## Tuning your answers + +If an answer isn't quite right — too chatty, wrong assumption, missing +context — **tune** Brain right there: + +1. Press **“Tune”** in the meta row under any completed answer (deflected + ones included). +2. Type a short instruction (1–2000 chars), e.g. *“be more concise”* or + *“assume I'm on NixOS”*, and **Save**. + +The note is stored in Postgres (`steering_notes`) and read into the +**system prompt of every subsequent chat turn** as a `` section +(numbered, oldest first, capped at `BOR_STEERING_MAX_CHARS` chars — +default 8000, overflow marked `[…truncated…]`). With no stored notes the +prompt is byte-identical to the un-tuned one, so tuning is opt-in per +note. + +List or remove notes at any time from the **“Tuning”** button in the chat +header (count badge, newest-first, per-note delete). The API is stateless +JSON if you prefer curl: + +```bash +curl -s localhost:8000/api/steering # list (newest first) +curl -s -X POST localhost:8000/api/steering \ + -H 'Content-Type: application/json' -d '{"note": "be more concise"}' +curl -s -X DELETE localhost:8000/api/steering/ # remove +``` + ## Updating the documents **This is the workflow you'll use most.** The knowledge base is refreshed by @@ -270,6 +298,7 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`. | `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) | | `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) | | `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM | +| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `` (steering notes) prompt section | | `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips | | `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) | | `BOR_LOG_LEVEL` | `INFO` | app log level | diff --git a/alembic/versions/0003_steering_notes.py b/alembic/versions/0003_steering_notes.py new file mode 100644 index 0000000..ad30a80 --- /dev/null +++ b/alembic/versions/0003_steering_notes.py @@ -0,0 +1,39 @@ +"""steering notes: owner tuning notes injected into every system prompt + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-08-22 + +Phase 15 (steering-notes story): the owner can "tune" how Brain answers +from the chat UI. Notes live in ``steering_notes`` and are read into the +system prompt of every subsequent chat turn (```` section). +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "steering_notes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("note", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("steering_notes") diff --git a/app/api/chat.py b/app/api/chat.py index 36b8238..74e6295 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -16,6 +16,11 @@ gets a grounded answer. Deflection mode carries weak-hit *titles only* (never document content) plus deterministic "Maybe try" chips, and the ``done`` event / ``query_log`` row record ``deflected=true``, the weak score and the ``fts_hits`` count. + +Steering (phase 15): the owner's stored tuning notes are loaded per turn +(oldest first) and injected into the system prompt as a ```` +section — both the HIGH and the LOW prompt carry it. The per-turn log +line records ``tuning=N`` (the number of injected notes). """ from __future__ import annotations @@ -30,6 +35,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy.orm import Session +from app.api.steering import load_steering_notes from app.config import Settings, get_settings from app.db import db_available, get_db from app.models import Document, QueryLog @@ -71,9 +77,14 @@ class TurnPlan: system_prompt: str docs: list[Document] # cited sources (weak hits when deflected) suggestions: list[str] # "Maybe try" chips (deflected turns only) + tuning_count: int = 0 # steering notes injected into the system prompt -def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan: +def plan_turn( + chunks: Sequence[RetrievedChunk], + settings: Settings, + notes: Sequence[str] | None = None, +) -> TurnPlan: """Apply the honesty gate (A8, revised) and assemble prompt + context. * **HIGH (grounded)** when ``best_cosine >= threshold`` **or** @@ -88,22 +99,36 @@ def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan: ``top_score`` (stored in ``query_log``) is the best cosine, so the gate input is always a pure vector-similarity number; the lexical signal is recorded separately as ``fts_hits``. + + *notes* are the owner's steering notes (phase 15, oldest first): + when non-empty, both the HIGH and the LOW prompt carry the + ```` section; with no notes the prompts are unchanged. """ + steering = list(notes or []) best_cosine = max((c.cosine for c in chunks), default=0.0) fts_hits = sum(1 for c in chunks if c.fts_hit) if best_cosine >= settings.relevance_threshold or fts_hits > 0: docs = select_documents( chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars ) - return TurnPlan(best_cosine, fts_hits, False, build_high_prompt(docs), docs, []) + return TurnPlan( + best_cosine, + fts_hits, + False, + build_high_prompt(docs, notes=steering), + docs, + [], + len(steering), + ) titles = weak_hit_titles(chunks) return TurnPlan( best_cosine, fts_hits, True, - build_deflect_prompt(titles), + build_deflect_prompt(titles, notes=steering), select_documents(chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars), derive_suggestions(titles, settings.suggestions), + len(steering), ) @@ -150,12 +175,14 @@ async def chat( return embed_ms = int((time.monotonic() - t0) * 1000) - # 2. Retrieve top-K chunks, then the honesty gate (A8) picks the - # HIGH (grounded) or LOW (deflected) prompt + context. + # 2. Retrieve top-K chunks, load the owner's steering notes + # (phase 15), then the honesty gate (A8) picks the HIGH + # (grounded) or LOW (deflected) prompt + context. settings = get_settings() try: + steering_notes = load_steering_notes(db) chunks = retrieve(db, request.message, question_vec) - plan = plan_turn(chunks, settings) + plan = plan_turn(chunks, settings, notes=steering_notes) except Exception: # noqa: BLE001 — DB failure mid-turn logger.exception( "chat: retrieval failed question=%r total_ms=%d", @@ -211,12 +238,13 @@ async def chat( logger.exception("chat: failed to write query_log question=%r", request.message) logger.info( - "question=%r embed_ms=%d top_score=%.3f fts_hits=%d threshold=%.2f " + "question=%r embed_ms=%d top_score=%.3f fts_hits=%d tuning=%d threshold=%.2f " "deflected=%s sources=%r total_ms=%d", request.message, embed_ms, plan.top_score, plan.fts_hits, + plan.tuning_count, settings.relevance_threshold, plan.deflected, source_paths, diff --git a/app/api/steering.py b/app/api/steering.py new file mode 100644 index 0000000..02ceec5 --- /dev/null +++ b/app/api/steering.py @@ -0,0 +1,74 @@ +"""Steering notes API — tune how Brain answers (phase 15, story +``steering-notes``). + +Stateless CRUD under ``/api/steering`` (A10): notes are owner instructions +stored in Postgres (``steering_notes``) and read into the system prompt of +**every** chat turn as the ```` section (see +:func:`app.rag.prompts.build_steering_section` and +:func:`app.api.chat.chat`). +""" +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db import get_db +from app.models import SteeringNote +from app.schemas import SteeringNote as SteeringNoteOut +from app.schemas import SteeringNoteIn, SteeringNoteList + +router = APIRouter(prefix="/steering", tags=["steering"]) + + +def load_steering_notes(db: Session) -> list[str]: + """All steering notes, oldest first (the order they are numbered in the + ```` prompt section). Used by the chat turn (``app.api.chat``).""" + rows = db.scalars( + select(SteeringNote).order_by(SteeringNote.created_at.asc(), SteeringNote.id.asc()) + ).all() + return [row.note for row in rows] + + +@router.get("", response_model=SteeringNoteList) +def list_steering_notes( + db: Session = Depends(get_db), # noqa: B008 +) -> SteeringNoteList: + """All notes, newest first (the UI panel's display order).""" + rows = db.scalars( + select(SteeringNote).order_by(SteeringNote.created_at.desc(), SteeringNote.id.desc()) + ).all() + return SteeringNoteList( + notes=[ + SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at) for row in rows + ] + ) + + +@router.post("", response_model=SteeringNoteOut, status_code=201) +def create_steering_note( + payload: SteeringNoteIn, + db: Session = Depends(get_db), # noqa: B008 +) -> SteeringNoteOut: + """Store one tuning instruction (trimmed, 1–2000 chars — 422 otherwise).""" + row = SteeringNote(note=payload.note) + db.add(row) + db.commit() + db.refresh(row) + return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at) + + +@router.delete("/{note_id}", status_code=204) +def delete_steering_note( + note_id: uuid.UUID, + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """Remove a note; 404 when the id is unknown.""" + row = db.get(SteeringNote, note_id) + if row is None: + raise HTTPException(status_code=404, detail="steering note not found") + db.delete(row) + db.commit() + return Response(status_code=204) diff --git a/app/config.py b/app/config.py index a152e50..2f1aa29 100644 --- a/app/config.py +++ b/app/config.py @@ -58,6 +58,10 @@ class Settings(BaseSettings): chunk_target_chars: int = 2_000 chunk_overlap_chars: int = 200 embed_batch_size: int = 16 + #: Total char budget for the ```` section of the system prompt + #: (phase 15, steering notes). The newest-fitting notes are kept and the + #: overflow is replaced by the ``[…truncated…]`` marker. + steering_max_chars: int = 8_000 # --- Hybrid retrieval (A7, revised 2026-08-21) --- # cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion diff --git a/app/main.py b/app/main.py index 483bdc0..d8ef342 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from fastapi.staticfiles import StaticFiles from app.api.chat import router as chat_router from app.api.docs import router as docs_router from app.api.health import router as health_router +from app.api.steering import router as steering_router from app.api.suggestions import router as suggestions_router from app.config import get_settings from app.core.debugging import configure_debugging @@ -36,6 +37,7 @@ def create_app() -> FastAPI: app.include_router(suggestions_router, prefix="/api") app.include_router(docs_router, prefix="/api") app.include_router(chat_router, prefix="/api") + app.include_router(steering_router, prefix="/api") static_dir = Path(settings.static_dir).resolve() if static_dir.is_dir(): diff --git a/app/models.py b/app/models.py index 94450f1..736a087 100644 --- a/app/models.py +++ b/app/models.py @@ -6,8 +6,10 @@ Data model — see ``.agent/PLAN.md`` §Data Model: * ``chunks`` — retrieval units; each chunk points at its parent document via ``document_id``. This is how an embedding maps back to a document path (the "feed the whole document" requirement). -* ``query_log`` — observability: every question, its retrieval score, the - deflection decision, and latency. +* ``query_log`` — observability: every question, its retrieval score, + the deflection decision, and latency. +* ``steering_notes`` — owner tuning notes injected into the system prompt + of every chat turn (phase 15, ```` section). """ from __future__ import annotations @@ -82,3 +84,18 @@ class QueryLog(Base): sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths latency_ms: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class SteeringNote(Base): + """One owner tuning instruction (phase 15). + + Notes are read into the system prompt of **every** chat turn as the + ```` section (oldest first, char-budgeted — see + :func:`app.rag.prompts.build_steering_section`). + """ + + __tablename__ = "steering_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + note: Mapped[str] = mapped_column(Text) # trimmed, 1–2000 chars (API-enforced) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/rag/prompts.py b/app/rag/prompts.py index 9ec5b65..b7efbed 100644 --- a/app/rag/prompts.py +++ b/app/rag/prompts.py @@ -1,24 +1,35 @@ """Locked system-prompt builder (PLAN §6). The persona + HONESTY GATE text is **locked verbatim** — change it through -the plan, not here. Two modes: +the plan, not here. (PLAN §6 revision, 2026-08-22: the owner's working-tree +persona edits are preserved — no mandated ``"you've got this"`` tagline and +no mandated deflection opening; the honesty gate itself is unchanged.) + +Two modes: * ``HIGH`` — grounded turn: full top-document texts under ````. * ``LOW`` — deflection turn: weak-hit *titles only* plus the ``DEFLECT_MODE`` marker (the E2E mock LLM keys on that marker). + +Steering (phase 15): when the owner has stored tuning notes, both modes +carry a ```` section between ``…`` and the +mode body. With zero notes the prompt is byte-identical to the +pre-steering text. """ from __future__ import annotations from collections.abc import Sequence +from app.config import get_settings from app.models import Document +from app.rag.retriever import TRUNCATION_MARKER #: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by #: :func:`_base`. PERSONA: str = ( 'You are "Brain of Reese" — the digital brain of Reese, a self-hoster and\n' "homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely\n" - 'optimistic about the user\'s ability to do things ("you\'ve got this").\n' + "optimistic about the user's ability to do things.\n" "\n" "Rules:\n" "1. Answer ONLY from the provided document context. Cite which document(s)\n" @@ -26,14 +37,20 @@ PERSONA: str = ( "2. Be concrete: names, versions, ports, hosts, schedules — the specifics in\n" " the docs are the value.\n" '3. HONESTY GATE: if is "LOW", you must NOT pretend to know.\n' - ' Start your answer with a variant of: "I haven\'t done anything like that."\n' - " Then offer 2-3 alternative questions about things you DO have notes on.\n" + " Offer 2-3 alternative questions about things you DO have notes on.\n" "4. Never invent facts, hosts, or steps that are not in the context.\n" "5. Keep answers tight: short paragraphs, bullets where helpful.\n" "\n" "{relevance}" ) +#: One-line intro of the ```` section (phase 15): the owner's notes +#: steer the answer and win over the defaults when they conflict. +_STEERING_INTRO = ( + "The owner of this brain asked you to steer your answers as follows. " + "Where these instructions conflict with the defaults above, follow the owner:\n" +) + def _base(relevance: str) -> str: if relevance not in ("HIGH", "LOW"): @@ -41,8 +58,46 @@ def _base(relevance: str) -> str: return PERSONA.replace("{relevance}", relevance) -def build_high_prompt(documents: Sequence[Document]) -> str: - """Grounded turn: locked persona + full texts of the top documents.""" +def build_steering_section(notes: Sequence[str], max_chars: int | None = None) -> str: + """The ```` section of the system prompt (phase 15). + + * No notes (or only blank ones) → ``""`` — callers then build the + prompt exactly as before, so a zero-note prompt is byte-identical to + the pre-steering text. + * Otherwise: numbered notes (in the given order — the chat turn passes + them oldest-first, so #1 is the oldest note) capped at *max_chars* + (default ``BOR_STEERING_MAX_CHARS``). When the budget cannot hold + every note, the oldest-fitting prefix is kept and the overflow is + replaced by the shared ``[…truncated…]`` marker. + """ + cleaned = [str(n).strip() for n in notes] + cleaned = [n for n in cleaned if n] + if not cleaned: + return "" + limit = max_chars if max_chars is not None else get_settings().steering_max_chars + if limit <= 0: + return "" + + def render(count: int) -> str: + lines = [f"{i}. {note}" for i, note in enumerate(cleaned[:count], start=1)] + if count < len(cleaned): + lines.append(TRUNCATION_MARKER) + return f"\n{_STEERING_INTRO}" + "\n".join(lines) + "\n" + + for count in range(len(cleaned), 0, -1): + rendered = render(count) + if len(rendered) <= limit: + return rendered + # Pathological budget: not even the empty note list fits. The section + # must still respect the cap — the bare marker when it fits, else none. + if len(TRUNCATION_MARKER) <= limit: + return TRUNCATION_MARKER + return "" + + +def build_high_prompt(documents: Sequence[Document], notes: Sequence[str] | None = None) -> str: + """Grounded turn: locked persona (+ steering) + full texts of the top + documents.""" blocks = [ f'\n' f"{doc.content}\n" @@ -52,15 +107,22 @@ def build_high_prompt(documents: Sequence[Document]) -> str: body = "\n\n".join(blocks) if blocks else ( "(no documents matched — do not invent specifics)" ) - return _base("HIGH") + "\n\n" + body + "\n" + section = build_steering_section(notes or []) + prompt = _base("HIGH") + if section: + prompt += "\n" + section + return prompt + "\n\n" + body + "\n" -def build_deflect_prompt(titles: Sequence[str]) -> str: +def build_deflect_prompt(titles: Sequence[str], notes: Sequence[str] | None = None) -> str: """Deflection turn: weak-hit titles only (no document content).""" weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)" + section = build_steering_section(notes or []) + mid = f"\n{section}\n" if section else "\n" return ( _base("LOW") - + "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest " + + mid + + "DEFLECT_MODE: retrieval was weak — the titles below are the closest " "your notes come to the question. They are titles only; do not pretend " "they answer it. Use them to propose 2-3 alternative questions.\n" + weak diff --git a/app/schemas.py b/app/schemas.py index 888b2f3..8e404a8 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,7 +1,10 @@ """Pydantic request/response schemas (API contract).""" from __future__ import annotations -from pydantic import BaseModel, Field +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator class HealthResponse(BaseModel): @@ -73,3 +76,33 @@ class DocContent(BaseModel): content: str indexed_at: str chunks: int + + +class SteeringNoteIn(BaseModel): + """``POST /api/steering`` body: one tuning instruction (phase 15). + + The note is trimmed *before* the length constraints run, so a + whitespace-only body is a 422 and a 2000-char note with surrounding + spaces still passes. + """ + + note: str = Field(min_length=1, max_length=2000) + + @field_validator("note", mode="before") + @classmethod + def _trim_note(cls, v: object) -> object: + return v.strip() if isinstance(v, str) else v + + +class SteeringNote(BaseModel): + """One stored steering note (API shape — ISO-8601 ``created_at``).""" + + id: uuid.UUID + note: str + created_at: datetime + + +class SteeringNoteList(BaseModel): + """``GET /api/steering`` response: all notes, newest first.""" + + notes: list[SteeringNote] diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 40aec4d..9a7f670 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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 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 = + ''; + +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 + "Tune"; + 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 = + `` + + ``; + 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 = + ''; + 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) diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 72fadf7..894ba0b 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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; } diff --git a/frontend/index.html b/frontend/index.html index 37b3837..631262d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,14 @@ Chat Sources + +