diff --git a/lyra/memory.py b/lyra/memory.py index 30c3b68..d245708 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -92,6 +92,21 @@ CREATE TABLE IF NOT EXISTS journal ( source TEXT ); CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at); + +-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections, +-- journal/metacognition). Stored as (context, content, rating) — the shape a future +-- fine-tune / preference dataset wants. One row per rated item (re-rating updates it). +CREATE TABLE IF NOT EXISTS ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + kind TEXT NOT NULL, -- chat | reflection | metacognition | journal + rating INTEGER NOT NULL, -- +1 (good / want more) or -1 (off / want less) + content TEXT NOT NULL, -- the rated output + context TEXT, -- what prompted it (e.g. the user message for a chat reply) + ref TEXT, -- optional source id (journal id, session id, ...) + note TEXT +); +CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at); """ _conn: sqlite3.Connection | None = None @@ -542,6 +557,41 @@ def add_journal_entry(kind: str, content: str, source: str | None = None) -> int return int(cur.lastrowid) +def add_rating(kind: str, rating: int, content: str, context: str | None = None, + ref: str | None = None, note: str | None = None) -> int: + """Record (or replace) Brian's feedback on one Lyra output. One row per item: + re-rating the same content updates it. Returns row id.""" + now = datetime.now(timezone.utc).isoformat() + conn = _connection() + with conn: + conn.execute("DELETE FROM ratings WHERE kind = ? AND content = ?", (kind, content)) + cur = conn.execute( + "INSERT INTO ratings (created_at, kind, rating, content, context, ref, note) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (now, kind, 1 if rating >= 0 else -1, content, context, + str(ref) if ref is not None else None, note), + ) + return int(cur.lastrowid) + + +def list_ratings(limit: int | None = None) -> list[dict]: + conn = _connection() + sql = "SELECT id, created_at, kind, rating, content, context, ref, note FROM ratings ORDER BY id DESC" + if limit is not None: + sql += f" LIMIT {int(limit)}" + return [dict(r) for r in conn.execute(sql).fetchall()] + + +def rating_counts() -> dict: + conn = _connection() + r = conn.execute( + "SELECT COUNT(*) AS total, " + "COALESCE(SUM(CASE WHEN rating > 0 THEN 1 ELSE 0 END), 0) AS up, " + "COALESCE(SUM(CASE WHEN rating < 0 THEN 1 ELSE 0 END), 0) AS down FROM ratings" + ).fetchone() + return {"total": r["total"], "up": r["up"], "down": r["down"]} + + def list_journal(limit: int | None = None, kinds: tuple[str, ...] | None = None) -> list[dict]: """Journal entries, newest first. Optionally filter by kind.""" conn = _connection() diff --git a/lyra/web/server.py b/lyra/web/server.py index 87a478b..655c2ac 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -142,6 +142,32 @@ def create_app() -> FastAPI: async def journal_data(limit: int = 300) -> dict: return {"entries": memory.list_journal(limit=limit)} + @app.post("/rate") + async def rate(request: Request) -> dict: + """Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal).""" + b = await request.json() + rating = int(b.get("rating", 0)) + content = (b.get("content") or "").strip() + if not content or rating == 0: + return {"ok": False} + memory.add_rating( + kind=b.get("kind") or "chat", rating=rating, content=content, + context=(b.get("context") or None), ref=b.get("ref"), note=b.get("note"), + ) + logbus.log("info", "rating", kind=b.get("kind"), rating=1 if rating >= 0 else -1) + return {"ok": True, "counts": memory.rating_counts()} + + @app.get("/ratings/counts") + async def ratings_counts() -> dict: + return memory.rating_counts() + + @app.get("/ratings/export") + async def ratings_export() -> Response: + """All ratings as JSONL — the seed for a future fine-tune / preference set.""" + lines = "\n".join(json.dumps(r) for r in memory.list_ratings()) + return Response(content=lines + ("\n" if lines else ""), media_type="application/x-ndjson", + headers={"Content-Disposition": 'attachment; filename="lyra_ratings.jsonl"'}) + @app.get("/hand/{hand_id}") async def hand_page(hand_id: int) -> FileResponse: """Replayable hand-history viewer.""" diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index fafc32f..801a93b 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -354,12 +354,46 @@ return out.join("\n"); } + function addRateBar(div) { + const bar = document.createElement("div"); + bar.className = "rate-bar"; + const up = document.createElement("button"); + up.className = "rate-btn"; up.textContent = "👍"; up.title = "Good — more like this"; + const down = document.createElement("button"); + down.className = "rate-btn"; down.textContent = "👎"; down.title = "Off — less like this"; + up.addEventListener("click", () => rateMessage(div, 1, up, down)); + down.addEventListener("click", () => rateMessage(div, -1, up, down)); + bar.appendChild(up); bar.appendChild(down); + div.appendChild(bar); + } + + function rateMessage(div, value, up, down) { + // context = the nearest preceding user message + let ctx = "", p = div.previousElementSibling; + while (p) { + if (p.classList && p.classList.contains("user")) { ctx = p.textContent; break; } + p = p.previousElementSibling; + } + fetch(`${RELAY_BASE}/rate`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "chat", rating: value, content: div.dataset.raw || "", context: ctx, session_id: currentSession }) + }).catch(() => {}); + up.classList.toggle("rated", value === 1); + down.classList.toggle("rated", value === -1); + } + function addMessage(role, text, autoScroll = true) { const messagesEl = document.getElementById("messages"); const msgDiv = document.createElement("div"); msgDiv.className = `msg ${role}`; - if (role === "assistant") { msgDiv.innerHTML = renderMarkdown(text); } else { msgDiv.textContent = text; } + if (role === "assistant") { + msgDiv.innerHTML = renderMarkdown(text); + msgDiv.dataset.raw = text; + addRateBar(msgDiv); + } else { + msgDiv.textContent = text; + } messagesEl.appendChild(msgDiv); // Auto-scroll to bottom if enabled diff --git a/lyra/web/static/journal.html b/lyra/web/static/journal.html index 4efba0f..89a9c73 100644 --- a/lyra/web/static/journal.html +++ b/lyra/web/static/journal.html @@ -52,6 +52,12 @@ .time { color: var(--fade); font-size: .72rem; } .src { color: var(--fade); font-size: .68rem; opacity: .7; } .text { font-size: .98rem; line-height: 1.55; } + .jrate { display: flex; gap: 8px; margin-top: 6px; opacity: .35; } + .entry:hover .jrate { opacity: .85; } + .jr { background: none; border: none; cursor: pointer; font-size: .85rem; padding: 2px 5px; + border-radius: 5px; filter: grayscale(.6); -webkit-tap-highlight-color: transparent; } + .jr:hover { filter: none; background: rgba(255,122,0,.12); } + .jr.rated { filter: none; background: rgba(255,122,0,.25); opacity: 1; } .empty { color: var(--fade); text-align: center; padding: 44px 16px; } .hidden { display: none !important; } @@ -115,12 +121,29 @@ ${e.source ? `via ${esc(e.source)}` : ''}