From 4530ab130d47456a626d12268f2da2438aafc20b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 03:03:46 +0530 Subject: [PATCH 001/200] UX v2 Phase 0: unify connection status across the three indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header pill, the home landing line, and the agent dock each computed connection state from their own signal at their own time. home.js read state.connected exactly once at tab init — before the /ws handshake — and never corrected, so the landing showed "Offline — start the agent to connect" while the header pill showed "Online" and state.connected was true. Add a single sticky ConnectionStatus store (status-store.js) holding three distinct signals — gentlyConnected (/ws), microscopeConnected (/api/device-status poll), agentConnected (/ws/agent) — which replays its current snapshot to every new subscriber, so a late subscriber can never miss the initial state (the root of the bug). All three surfaces now read from / write to this store: - websocket.js onopen/onclose -> setGently (via updateGentlyStatus) - app.js fetchDeviceStatus -> setMicroscope; header renders via subscriber - home.js updateStatus reads the store and re-renders on every change - agent-chat.js setConn -> setAgent Verified live: after reload the home line and header pill agree (no more "Offline while Online"); no console errors. Bug #3 (idle event-count inflation) needs no code change: the high-frequency telemetry (DEVICE_STATE_UPDATE/BOTTOM_CAMERA_FRAME) is already excluded from the events table + count at websocket.js, and idle measurement showed the count is calm and dominated by LOG_RECORD. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/js/agent-chat.js | 2 + gently/ui/web/static/js/app.js | 37 ++++++++++++----- gently/ui/web/static/js/home.js | 14 ++++++- gently/ui/web/static/js/status-store.js | 54 +++++++++++++++++++++++++ gently/ui/web/templates/index.html | 1 + 5 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 gently/ui/web/static/js/status-store.js diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 63339b92..620c9d60 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -636,6 +636,8 @@ const AgentChat = (() => { conn.classList.toggle('ac-conn-bad', !ok); conn.textContent = label || (ok ? 'Connected' : 'Reconnecting…'); if (toggleDot) toggleDot.classList.toggle('ok', ok); + // Feed the shared connection store (agent /ws/agent liveness). + if (typeof ConnectionStatus !== 'undefined') ConnectionStatus.setAgent(ok); } // ── Transport ───────────────────────────────────────────── diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index d1e75a72..3395c89b 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -538,11 +538,13 @@ function fetchDeviceStatus() { .then(r => r.json()) .then(data => { _microscopeConnected = data.microscope; - _setBadge('status-microscope-badge', data.microscope, 'Online', 'Offline'); - updateTopLevelDot(); + ConnectionStatus.setMicroscope(data.microscope); }) .catch(() => { - _setBadge('status-microscope-badge', false, '', '--'); + // Transient poll failure: keep the last-known badge. The next + // successful poll re-renders via the store if the value changed + // (writing '--' here could stick, since the store only re-renders + // on an actual change, not on an unchanged success). }); } @@ -555,23 +557,26 @@ function _setBadge(id, isOn, onText, offText) { } function updateGentlyStatus(connected) { - _setBadge('status-gently-badge', connected, 'Online', 'Offline'); - updateTopLevelDot(); + // Feed the single source of truth; the header re-renders via the + // ConnectionStatus subscriber (renderConnectionUI). + ConnectionStatus.setGently(connected); } -function updateTopLevelDot() { +// Single renderer for the header connection UI, driven by a ConnectionStatus +// snapshot. Subscribed once at startup, so the pill, both popover badges, and +// the dot always reflect the same shared state. +function renderConnectionUI(s) { + _setBadge('status-gently-badge', s.gentlyConnected, 'Online', 'Offline'); + _setBadge('status-microscope-badge', s.microscopeConnected, 'Online', 'Offline'); const dot = document.getElementById('status-dot'); const text = document.getElementById('status-text'); if (!dot || !text) return; - const gentlyUp = state.connected; - const scopeUp = _microscopeConnected; - dot.classList.remove('connected', 'partial'); - if (gentlyUp && scopeUp) { + if (s.gentlyConnected && s.microscopeConnected) { dot.classList.add('connected'); text.textContent = 'Connected'; - } else if (gentlyUp) { + } else if (s.gentlyConnected) { dot.classList.add('partial'); text.textContent = 'Online'; } else { @@ -579,6 +584,11 @@ function updateTopLevelDot() { } } +// Back-compat shim: any legacy caller re-renders from the current snapshot. +function updateTopLevelDot() { + renderConnectionUI(ConnectionStatus.get()); +} + document.addEventListener('DOMContentLoaded', () => { // Initialize presence manager (before WebSocket so ID is ready) PresenceManager.init(); @@ -620,6 +630,11 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // Connection status: one source of truth, three writers (this /ws, the + // device-status poll, and the agent /ws/agent). Subscribe the header + // renderer BEFORE connecting so the first handshake renders correctly. + ConnectionStatus.subscribe(renderConnectionUI); + // Start WebSocket connection connectWebSocket(); diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js index 089d7de3..bf5cad17 100644 --- a/gently/ui/web/static/js/home.js +++ b/gently/ui/web/static/js/home.js @@ -136,7 +136,13 @@ const HomeApp = (() => { function updateStatus() { const el = document.getElementById('home-status'); if (!el) return; - const connected = (typeof state !== 'undefined' && state.connected); + // Read the shared ConnectionStatus store, not a one-shot snapshot of + // state.connected — the latter was read once at tab init (before the + // /ws handshake) and never corrected, showing "Offline" while the + // header pill said "Online". + const connected = (typeof ConnectionStatus !== 'undefined') + ? ConnectionStatus.get().gentlyConnected + : (typeof state !== 'undefined' && state.connected); const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; el.textContent = connected ? `Connected · ${n} embryo${n === 1 ? '' : 's'} in view` @@ -162,6 +168,12 @@ const HomeApp = (() => { if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand('/wizard'), 250); } }); + // Re-render the status line on every connection change. subscribe() + // replays the current snapshot immediately, so a late init still + // renders correct state. Registered once (inside the _inited guard). + if (typeof ConnectionStatus !== 'undefined') { + ConnectionStatus.subscribe(() => updateStatus()); + } } refresh(); // re-fetch on every entry to the tab } diff --git a/gently/ui/web/static/js/status-store.js b/gently/ui/web/static/js/status-store.js new file mode 100644 index 00000000..0e50dd30 --- /dev/null +++ b/gently/ui/web/static/js/status-store.js @@ -0,0 +1,54 @@ +/** + * ConnectionStatus — the single source of truth for connection liveness. + * + * Fixes the "three disagreeing indicators" bug where the header pill, the home + * landing line, and the agent dock each computed connection state from their + * own signal at their own time (home.js read state.connected ONCE at tab init, + * before the /ws handshake, and never corrected — showing "Offline" while the + * header showed "Online"). + * + * Three genuinely distinct signals (kept separate, not flattened): + * - gentlyConnected : the main /ws telemetry socket (websocket.js) + * - microscopeConnected : the /api/device-status health poll (app.js) + * - agentConnected : the /ws/agent chat socket (agent-chat.js) + * + * Writers call set*(); readers subscribe(). The store is STICKY: subscribe() + * immediately replays the current snapshot to the new subscriber, so a late + * subscriber can never miss the initial state. Events only fire on real change. + */ +const ConnectionStatus = (() => { + const s = { gentlyConnected: false, microscopeConnected: false, agentConnected: false }; + + function emit() { + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.emit('CONNECTION_STATUS', { ...s }); + } + } + + function set(key, val) { + val = !!val; + if (s[key] === val) return; // only emit on actual change + s[key] = val; + emit(); + } + + return { + setGently(v) { set('gentlyConnected', v); }, + setMicroscope(v) { set('microscopeConnected', v); }, + setAgent(v) { set('agentConnected', v); }, + get() { return { ...s }; }, + + /** + * Subscribe to status changes AND immediately receive the current + * snapshot (sticky replay). This is the guard against the original bug: + * a subscriber that registers after the first emit still renders from + * the correct current state instead of a stale default. + */ + subscribe(handler) { + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONNECTION_STATUS', handler); + } + try { handler({ ...s }); } catch (e) { console.error('ConnectionStatus subscriber error', e); } + } + }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 85a60345..9aaf279a 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -616,6 +616,7 @@

Properties

+ From 4571a58cba470a1e704c4fd66fa0344a80e69057 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 03:23:23 +0530 Subject: [PATCH 002/200] UX v2 Phase 1 (scaffold): add GENTLY_UX_V2 feature flag Adds UISettings.ux_v2 (env GENTLY_UX_V2, default off) and threads it into the index.html template context from pages.py. This is the coexistence switch for the agent-first UX: the v2 markup/JS will mount only under this flag, so the v1 dashboard stays the default and prod is unaffected while the migration soaks behind the flag. No behaviour change yet (flag off by default; nothing reads it client-side until the Phase 1 dual-render lands). Co-Authored-By: Claude Fable 5 --- gently/settings.py | 11 +++++++++++ gently/ui/web/routes/pages.py | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/gently/settings.py b/gently/settings.py index 68cebd38..8b16f871 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -120,6 +120,16 @@ class TransferSettings: ) +@dataclass(frozen=True) +class UISettings: + """Web UI feature flags.""" + # New agent-first UX paradigm (welcome→shell unfold, dual-rendered agent + # asks, inference-first plan mode, shared-visibility surface). Off by + # default; flip per-deployment via GENTLY_UX_V2=1 while the migration + # soaks behind the flag. The v1 dashboard stays the default until then. + ux_v2: bool = field(default_factory=lambda: _env("UX_V2", False)) + + @dataclass(frozen=True) class Settings: """Top-level settings container.""" @@ -132,6 +142,7 @@ class Settings: api: ApiSettings = field(default_factory=ApiSettings) ml: MlSettings = field(default_factory=MlSettings) transfer: TransferSettings = field(default_factory=TransferSettings) + ui: UISettings = field(default_factory=UISettings) # Singleton — import this everywhere diff --git a/gently/ui/web/routes/pages.py b/gently/ui/web/routes/pages.py index 3858f4e7..04a1a959 100644 --- a/gently/ui/web/routes/pages.py +++ b/gently/ui/web/routes/pages.py @@ -3,6 +3,8 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse +from gently.settings import settings + def create_router(server) -> APIRouter: router = APIRouter() @@ -16,7 +18,9 @@ async def index(request: Request): chat window's "Sign in" affordance), not a gate on the page itself. """ return server.templates.TemplateResponse( - request, "index.html", {"active_section": "embryos", "is_live": True} + request, + "index.html", + {"active_section": "embryos", "is_live": True, "ux_v2": settings.ui.ux_v2}, ) # Standalone URLs redirect to SPA with hash fragment for tab routing From 17e66cbfb81567dc2b69aee6c2c20ea24f4601dd Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 03:58:13 +0530 Subject: [PATCH 003/200] UX v2 Phase 1: dual-render agent asks (chat transcript + main stage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's structured asks (choice_request / choice_response over /ws/agent) already ARE the one-payload protocol. This makes the SAME ask render both in the chat transcript and prominently on a new main-stage surface (#ask-stage), behind GENTLY_UX_V2 — the foundation for the agent-first paradigm. Frontend only; no wire-protocol or backend change. The double-answer and turn-wedge concerns are handled without touching the server: - agent-chat.js renderChoice is factored into a pure buildAskCard(data, {reqId, isWake, hasControl, onPick}) reused by both surfaces; exported alongside answerChoice + a hasControl getter. - A module-level answeredAsks Set keyed by request_id makes answering idempotent across both surfaces (only ONE choice_response is ever sent), so the existing holder-gate + _choice_futures.pop on the server stay correct and never see a duplicate. - The CLEAR signal fires off the CHOICE lifecycle: answerChoice emits ASK_CLEARED{request_id} the instant a response is sent (NOT stream_end, which lands after the answer for in-turn asks and never for a cancelled turn). Both surfaces clear on it; '*' clears all on cancel/error/socket-drop. - Read-only when !hasControl on both surfaces (observers can't answer), matching the server's holder gate — no dismiss-without-answer path, so asend can't wedge. - Adds the free-text "Something else…" escape the web cards lacked (the bridge routes unknown selections to LLM resolution). ask-stage.js (new) renders the current ask into #ask-stage via AgentChat.buildAskCard and clears on ASK_CLEARED; no-ops unless #ask-stage is present (flag off → v1 untouched). Verified: node --check on all touched JS, Jinja parse on index.html. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/css/ask-stage.css | 42 ++++++++ gently/ui/web/static/js/agent-chat.js | 132 ++++++++++++++++++++----- gently/ui/web/static/js/ask-stage.js | 53 ++++++++++ gently/ui/web/templates/index.html | 7 +- 4 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 gently/ui/web/static/css/ask-stage.css create mode 100644 gently/ui/web/static/js/ask-stage.js diff --git a/gently/ui/web/static/css/ask-stage.css b/gently/ui/web/static/css/ask-stage.css new file mode 100644 index 00000000..d706a05c --- /dev/null +++ b/gently/ui/web/static/css/ask-stage.css @@ -0,0 +1,42 @@ +/* Main-stage ask surface (ux_v2): the agent's current pending ask, rendered + prominently outside the chat transcript. Reuses the .ac-choice card markup + from agent-chat.css; this file frames the stage container and adds the + shared free-text ("Something else…") escape styling. Only #ask-stage is + gated behind the flag, so loading this CSS unconditionally is harmless. */ + +.ask-stage { margin: 14px 16px 0; } +.ask-stage.hidden { display: none; } + +.ask-stage .ac-choice { + border: 1px solid var(--border, #e4e9f0); + border-radius: 14px; + padding: 16px 18px; + background: var(--surface, #fff); + box-shadow: 0 8px 28px rgba(15, 23, 42, .08); +} +.ask-stage .ac-choice-q { + font-size: 1.02rem; + font-weight: 600; + margin-bottom: 12px; +} + +/* Free-text "Something else…" escape — present on ask cards in BOTH surfaces. */ +.ac-choice-otherwrap { margin-top: 6px; } +.ac-choice-other.hidden, +.ac-choice-otherform.hidden { display: none; } +.ac-choice-otherform { display: flex; gap: 6px; align-items: center; margin-top: 4px; } +.ac-choice-otherinput { + flex: 1; min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border, #cbd5e1); + border-radius: 8px; + font: inherit; + background: var(--surface, #fff); + color: inherit; +} +.ac-choice-otherinput:focus { outline: none; border-color: var(--accent, #2f6df6); } +.ac-choice-othergo { + border: 0; cursor: pointer; + background: var(--accent, #2f6df6); color: #fff; + border-radius: 8px; padding: 8px 12px; line-height: 1; +} diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 620c9d60..c032e67a 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -16,6 +16,7 @@ const AgentChat = (() => { let panelOpen = false; let hasControl = true; // optimistic until the server says otherwise + const answeredAsks = new Set(); // request_ids answered from EITHER surface (transcript / main stage) let holderLabel = null; let streaming = false; let currentAgentEl = null; // the agent content element being streamed into @@ -211,6 +212,8 @@ const AgentChat = (() => { hasControl = !!msg.you_have_control; holderLabel = msg.holder_label || null; renderControl(); + // The main-stage ask renderer re-renders read-only on control loss. + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('AGENT_CONTROL', { hasControl }); break; case 'history': @@ -342,6 +345,7 @@ const AgentChat = (() => { hideActivity(); setBusy(false); addSystemLine(msg.error || 'Unknown error', 'error'); + clearPendingAsks(); // a cancelled/errored turn sends no choice_response break; case 'ping': @@ -353,12 +357,19 @@ const AgentChat = (() => { } } - function renderChoice(msg) { - const data = msg.choice_data || {}; - const reqId = msg.request_id || data.request_id || ''; - const isWake = msg.origin === 'wake'; + // Build an ask card from a choice_data payload. Pure: the caller supplies + // hasControl + onPick, so the SAME builder renders in the chat transcript + // and on the main stage (#ask-stage) — one payload, two renderers. + function buildAskCard(data, opts) { + opts = opts || {}; + const reqId = opts.reqId || ''; + const isWake = !!opts.isWake; + const canAct = !!opts.hasControl && !answeredAsks.has(reqId); + const onPick = opts.onPick || function () {}; + const wrap = document.createElement('div'); wrap.className = 'ac-choice' + (isWake ? ' ac-choice-wake' : ''); + wrap.dataset.reqId = reqId; if (isWake) { const tag = document.createElement('div'); tag.className = 'ac-choice-origin'; @@ -373,30 +384,101 @@ const AgentChat = (() => { (data.options || []).forEach(opt => { const btn = document.createElement('button'); btn.className = 'ac-choice-opt'; - btn.disabled = !!opt.disabled || !hasControl; // observers see it read-only + btn.disabled = !!opt.disabled || !canAct; // observers / already-answered → read-only const desc = opt.description ? `${escapeHtml(opt.description)}` : ''; btn.innerHTML = `${escapeHtml(opt.label)}${desc}`; - btn.addEventListener('click', () => { - send({ type: 'choice_response', request_id: reqId, selected: opt.id }); - [...wrap.querySelectorAll('button')].forEach(b => b.disabled = true); - wrap.classList.add('ac-choice-answered'); - btn.classList.add('ac-choice-picked'); - if (streaming) setActivity('Working…'); - if (isWake && pendingSlot) { - setTimeout(() => { pendingSlot.classList.add('hidden'); pendingSlot.innerHTML = ''; }, 700); - } - }); + btn.addEventListener('click', () => onPick(opt.id)); wrap.appendChild(btn); }); - // ASK approvals pin to the sticky slot above the composer so they can't - // scroll out of reach; ordinary choices stay inline in the transcript. + + // Free-text escape — the bridge routes an unknown selection to LLM + // resolution, so the agent's asend always unblocks. (The TUI had this; + // the web ask cards previously did not.) + if (canAct) { + const ow = document.createElement('div'); + ow.className = 'ac-choice-otherwrap'; + const otherBtn = document.createElement('button'); + otherBtn.className = 'ac-choice-opt ac-choice-other'; + otherBtn.innerHTML = 'Something else…'; + const form = document.createElement('div'); + form.className = 'ac-choice-otherform hidden'; + const ti = document.createElement('input'); + ti.type = 'text'; + ti.className = 'ac-choice-otherinput'; + ti.placeholder = 'Type your own answer…'; + const go = document.createElement('button'); + go.className = 'ac-choice-othergo'; + go.textContent = '→'; + const submitOther = () => { const v = ti.value.trim(); if (v) onPick(v); }; + otherBtn.addEventListener('click', () => { otherBtn.classList.add('hidden'); form.classList.remove('hidden'); ti.focus(); }); + go.addEventListener('click', submitOther); + ti.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submitOther(); } }); + form.appendChild(ti); form.appendChild(go); + ow.appendChild(otherBtn); ow.appendChild(form); + wrap.appendChild(ow); + } + + if (answeredAsks.has(reqId)) wrap.classList.add('ac-choice-answered'); + return wrap; + } + + // Send the answer ONCE (idempotent across both surfaces), then fire the + // clear off the CHOICE lifecycle — NOT stream_end (which lands after the + // answer for in-turn asks, and never for a cancelled turn). + function answerChoice(reqId, selected) { + if (!reqId || answeredAsks.has(reqId)) return; + answeredAsks.add(reqId); + send({ type: 'choice_response', request_id: reqId, selected }); + if (streaming) setActivity('Working…'); + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('ASK_CLEARED', { request_id: reqId }); + } + + // Disable + mark-answered any transcript / sticky-slot ask card for this + // request_id ('*' = all). The main stage clears itself via its own handler. + function markAnswered(reqId) { + [log, pendingSlot].forEach(scope => { + if (!scope) return; + scope.querySelectorAll('.ac-choice').forEach(card => { + if (reqId !== '*' && card.dataset.reqId !== reqId) return; + card.querySelectorAll('button').forEach(b => b.disabled = true); + card.classList.add('ac-choice-answered'); + }); + }); + if (pendingSlot) { + const slotCard = pendingSlot.querySelector('.ac-choice'); + if (reqId === '*' || (slotCard && slotCard.dataset.reqId === reqId)) { + setTimeout(() => { pendingSlot.classList.add('hidden'); pendingSlot.innerHTML = ''; }, 700); + } + } + } + + // Retire all pending asks (turn cancelled/errored, or socket dropped). + function clearPendingAsks() { + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('ASK_CLEARED', { request_id: '*' }); + else markAnswered('*'); + } + + function renderChoice(msg) { + const data = msg.choice_data || {}; + const reqId = msg.request_id || data.request_id || ''; + const isWake = msg.origin === 'wake'; + const card = buildAskCard(data, { + reqId, isWake, hasControl, + onPick: (sel) => answerChoice(reqId, sel), + }); + // Mirror onto the main stage (ask-stage.js renders it prominently). + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.emit('AGENT_ASK', { request_id: reqId, choice_data: data, origin: msg.origin }); + } + // ASK approvals pin to the sticky slot above the composer; ordinary + // choices stay inline in the transcript. if (isWake && pendingSlot) { pendingSlot.innerHTML = ''; - pendingSlot.appendChild(wrap); + pendingSlot.appendChild(card); pendingSlot.classList.remove('hidden'); return; } - log.appendChild(wrap); + log.appendChild(card); scrollToBottom(); } @@ -655,6 +737,7 @@ const AgentChat = (() => { setBusy(false); streaming = false; hideActivity(); + clearPendingAsks(); // stale asks: clear the stage on socket drop setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); }; @@ -873,6 +956,11 @@ const AgentChat = (() => { if (!panel) return; // markup not present restorePrefs(); + // Dual-render: retire a transcript ask card when its ask is answered + // (from the transcript OR the main stage) or the turn is cancelled. + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => markAnswered(request_id)); + } if (toggleBtn) toggleBtn.addEventListener('click', () => togglePanel()); closeBtn.addEventListener('click', () => togglePanel(false)); if (pinBtn) pinBtn.addEventListener('click', togglePin); @@ -914,7 +1002,7 @@ const AgentChat = (() => { stopBtn.className = 'ac-stop hidden'; stopBtn.textContent = 'Stop'; stopBtn.title = 'Stop the current turn'; - stopBtn.addEventListener('click', () => { send({ type: 'cancel' }); setBusy(false); }); + stopBtn.addEventListener('click', () => { send({ type: 'cancel' }); setBusy(false); clearPendingAsks(); }); inputWrap.appendChild(stopBtn); // Sticky ASK-approval slot — above the queue + composer, never scrolls away. @@ -951,7 +1039,7 @@ const AgentChat = (() => { // Escape mirrors Stop: cancel a cancellable (user) turn and clear busy // (a cancelled turn emits no stream_end, so clear optimistically). if (e.key === 'Escape' && agentBusy && busySource === 'user') { - e.preventDefault(); send({ type: 'cancel' }); setBusy(false); + e.preventDefault(); send({ type: 'cancel' }); setBusy(false); clearPendingAsks(); } }); } @@ -966,5 +1054,5 @@ const AgentChat = (() => { actuallySend(text); } - return { togglePanel, runCommand }; + return { togglePanel, runCommand, buildAskCard, answerChoice, hasControl: () => hasControl }; })(); diff --git a/gently/ui/web/static/js/ask-stage.js b/gently/ui/web/static/js/ask-stage.js new file mode 100644 index 00000000..b5e63db1 --- /dev/null +++ b/gently/ui/web/static/js/ask-stage.js @@ -0,0 +1,53 @@ +/** + * AskStage (ux_v2) — renders the agent's CURRENT pending ask prominently on the + * main stage, in addition to the chat transcript. One payload, two renderers: + * it reuses AgentChat.buildAskCard so the stage and the transcript can't drift, + * and answering from either surface clears both (via the ASK_CLEARED event that + * AgentChat fires off the CHOICE lifecycle — not stream_end, which arrives only + * after an in-turn answer and never for a cancelled turn). + * + * No-ops unless #ask-stage is present (gated behind GENTLY_UX_V2 in the + * template), so it never affects the v1 dashboard. + */ +const AskStage = (() => { + let stageEl = null; + let current = null; // { reqId, data, isWake } + + function clear() { + current = null; + if (stageEl) { stageEl.innerHTML = ''; stageEl.classList.add('hidden'); } + } + + function render() { + if (!stageEl || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(current.data, { + reqId: current.reqId, + isWake: current.isWake, + hasControl, + onPick: (sel) => AgentChat.answerChoice(current.reqId, sel), + }); + stageEl.innerHTML = ''; + stageEl.appendChild(card); + stageEl.classList.remove('hidden'); + } + + function init() { + stageEl = document.getElementById('ask-stage'); + if (!stageEl || typeof ClientEventBus === 'undefined') return; // ux_v2 off → no-op + + ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { + current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; + render(); + }); + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { + if (!current) return; + if (request_id === '*' || request_id === current.reqId) clear(); + }); + // Re-render read-only / actionable when control changes hands mid-ask. + ClientEventBus.on('AGENT_CONTROL', () => { if (current) render(); }); + } + + document.addEventListener('DOMContentLoaded', init); + return { clear }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 9aaf279a..b02568d8 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -15,8 +15,9 @@ + - + {% include '_header.html' %} {% include '_navbar.html' %}
@@ -637,5 +641,6 @@

Properties

+ From 52503e706914e03732b51b12240fb0258a524bcc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 04:15:58 +0530 Subject: [PATCH 004/200] UX v2 Phase 2: grouped rail nav + session-context strip (behind flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat 8-tab bar with a calm grouped left rail (Now / Library / System) and adds a session-context strip at the top of the main area — the structural transformation toward the prototype's shell. All scoped under body.ux-v2; v1 markup/CSS untouched (no consolidation of the known duplicate .tab rulesets — deferred to the final cleanup phase). - shell.js (new): wires each rail item to switchTab(tabId) — it ROUTES through the single init chokepoint, never reimplements tab activation, so every tab's lazy-init side-effect still fires. Keeps the rail's active state in sync via a new TAB_CHANGED event; populates the strip's status/embryo count from the Phase 0 ConnectionStatus store. Wires the rail's "Talk to Gently" to the existing AgentChat dock. No-ops unless body.ux-v2 is present. - app.js: switchTab now emits TAB_CHANGED(tabName) — additive; v1 has no listener, so no behaviour change. - index.html: the rail (first child of the flex-row .app-shell) + the strip (top of .app-main) + shell.css/shell.js includes, all under {% if ux_v2 %}. - shell.css (new): rail + strip styling and a subtle unfold animation, every rule scoped under body.ux-v2. Deferred to keep this phase low-risk: History-API routing and the session_changed in-place re-hydration (current hash routing + reload still work). Verified: node --check on all touched JS, Jinja parse on index.html. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/css/shell.css | 79 ++++++++++++++++++++++++++++++ gently/ui/web/static/js/app.js | 2 + gently/ui/web/static/js/shell.js | 58 ++++++++++++++++++++++ gently/ui/web/templates/index.html | 28 +++++++++++ 4 files changed, 167 insertions(+) create mode 100644 gently/ui/web/static/css/shell.css create mode 100644 gently/ui/web/static/js/shell.js diff --git a/gently/ui/web/static/css/shell.css b/gently/ui/web/static/css/shell.css new file mode 100644 index 00000000..7501572f --- /dev/null +++ b/gently/ui/web/static/css/shell.css @@ -0,0 +1,79 @@ +/* ux_v2 shell chrome: grouped left-rail nav + session-context strip. + Everything is scoped under body.ux-v2 so the v1 dashboard is byte-for-byte + untouched — no consolidation of the existing duplicate .tab rulesets here + (that cleanup is deferred to the final phase). */ + +/* Replace the flat 8-tab bar with the rail. */ +body.ux-v2 .tabs { display: none; } + +/* ── Left rail ─────────────────────────────────────────────── */ +body.ux-v2 .v2-rail { + flex: 0 0 212px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 14px 10px; + border-right: 1px solid var(--border, #e4e9f0); + background: var(--bg-card, #fff); + overflow-y: auto; + animation: v2-rise .45s ease backwards; +} +body.ux-v2 .v2-nav-group { margin-bottom: 6px; } +body.ux-v2 .v2-nav-label { + font-size: 10px; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-muted, #94a3b8); padding: 10px 10px 4px; +} +body.ux-v2 .v2-nav-item { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: 0; cursor: pointer; text-align: left; + padding: 8px 10px; border-radius: 8px; + font: inherit; font-size: 13.5px; + color: var(--text-secondary, #475569); + transition: background .15s, color .15s; +} +body.ux-v2 .v2-nav-item:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); } +body.ux-v2 .v2-nav-item.active { + background: var(--accent-soft, #eaf1ff); + color: var(--accent, #2f6df6); + font-weight: 600; +} +body.ux-v2 .v2-rail-chat { + margin-top: auto; + display: flex; align-items: center; gap: 9px; + background: none; border: 1px solid var(--border, #e4e9f0); border-radius: 10px; + padding: 9px 12px; cursor: pointer; + font: inherit; font-size: 13px; + color: var(--text-secondary, #475569); + transition: border-color .15s, color .15s; +} +body.ux-v2 .v2-rail-chat:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); } +body.ux-v2 .v2-rail-orb { + width: 18px; height: 18px; border-radius: 50%; flex: none; + background: radial-gradient(closest-side at 38% 34%, #fff, #bcd3ff 42%, var(--accent, #2f6df6) 100%); +} + +/* ── Session-context strip (top of main) ───────────────────── */ +body.ux-v2 .v2-strip { + flex: none; + display: flex; align-items: center; gap: 12px; + padding: 9px 16px; + border-bottom: 1px solid var(--border, #e4e9f0); + background: var(--bg-card, #fff); + font-size: 12.5px; color: var(--text-muted, #94a3b8); + animation: v2-rise .45s ease backwards .05s; +} +body.ux-v2 .v2-strip-live { + display: inline-flex; align-items: center; gap: 6px; + font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: #ef4444; +} +body.ux-v2 .v2-strip-dot { + width: 8px; height: 8px; border-radius: 50%; background: #ef4444; +} +body.ux-v2 .v2-strip-status { margin-left: auto; font-variant-numeric: tabular-nums; } + +body.ux-v2 .app-main { animation: v2-rise .5s ease backwards .1s; } + +@keyframes v2-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { + body.ux-v2 .v2-rail, body.ux-v2 .v2-strip, body.ux-v2 .app-main { animation: none; } +} diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index 3395c89b..a8c6450c 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -60,6 +60,8 @@ function updateCalibrationCount() { function switchTab(tabName) { if (!tabName) return; state.tab = tabName; + // ux_v2 grouped rail mirrors the active tab off this single chokepoint. + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('TAB_CHANGED', tabName); // Update tab styling document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); diff --git a/gently/ui/web/static/js/shell.js b/gently/ui/web/static/js/shell.js new file mode 100644 index 00000000..c85b2474 --- /dev/null +++ b/gently/ui/web/static/js/shell.js @@ -0,0 +1,58 @@ +/** + * Shell (ux_v2): the grouped left-rail nav (Now / Library / System) + the + * session-context strip that replace the flat 8-tab bar. + * + * CRITICAL: the rail ROUTES THROUGH switchTab(tabId) for every reveal — it + * never reimplements tab activation, so each tab's lazy-init side-effect + * (HomeApp.init, EmbryosManager.clearDetectionBadge, CampaignsApp.init, …) + * still fires. switchTab emits TAB_CHANGED, which keeps the rail's active + * state in sync no matter who switched (rail, keyboard shortcut, home card, + * hash route). No-ops unless body.ux-v2 is present (flag off → v1 untouched). + */ +const Shell = (() => { + let railItems = []; + + function setActive(tabName) { + railItems.forEach(b => b.classList.toggle('active', b.dataset.tab === tabName)); + } + + function currentTab() { + const active = document.querySelector('.tab.active'); + return (active && active.dataset.tab) || + (typeof state !== 'undefined' && state.tab) || 'home'; + } + + function renderStrip(status) { + const el = document.getElementById('v2-strip-status'); + if (!el) return; + const s = status || (typeof ConnectionStatus !== 'undefined' ? ConnectionStatus.get() : {}); + const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; + const conn = s.gentlyConnected ? (s.microscopeConnected ? 'Connected' : 'Online') : 'Offline'; + el.textContent = `${n} embryo${n === 1 ? '' : 's'} · ${conn}`; + } + + function init() { + if (!document.body.classList.contains('ux-v2')) return; // flag off → no-op + + railItems = Array.from(document.querySelectorAll('.v2-nav-item')); + railItems.forEach(btn => btn.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab(btn.dataset.tab); + })); + setActive(currentTab()); + + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('TAB_CHANGED', (tabName) => setActive(tabName)); + ClientEventBus.on('CONNECTION_STATUS', (s) => renderStrip(s)); + } + + const chatBtn = document.getElementById('v2-rail-chat'); + if (chatBtn) chatBtn.addEventListener('click', () => { + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) AgentChat.togglePanel(true); + }); + + renderStrip(); + } + + document.addEventListener('DOMContentLoaded', init); + return {}; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index b02568d8..4bcf99c0 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -16,6 +16,7 @@ + {% include '_header.html' %} @@ -23,8 +24,34 @@
+ {% if ux_v2 %} + + {% endif %}
+ {% if ux_v2 %}
LIVE
{% endif %} + {# ux_v2: the agent's current pending ask, dual-rendered here + in the chat. #} {% if ux_v2 %}{% endif %} @@ -642,5 +669,6 @@

Properties

+ From 288f97b8e6caf3e18e5f1add65104442cb40cf73 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 04:33:43 +0530 Subject: [PATCH 005/200] UX v2 Phase 3a: inference-first plan mode (model-driven, with provenance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips plan mode from ASK-FIRST to INFERENCE-FIRST: the agent arrives with a draft instead of interrogating the researcher. Per Keshu's call, the genotype -> imaging-channel inference is done by the MODEL (it reads the reporters and knows fluorophore spectra) — NOT a hardcoded heuristic table, which would only cover a fraction of real fluorophores/dyes and force needless "asks". - prompt.py: the stance now says infer what you can (channels from the strain genotype via your own fluorophore knowledge, organism defaults, lab/campaign context), record each inferred value's source + confidence in the spec's provenance, state a wavelength only when confident (else mark low-confidence and confirm via ask_user_choice — never guess a number), and ask ONLY for genuine gaps / low-confidence / consequential choices. - model.py: ImagingSpec gains a `provenance` map (field -> {source, confidence}). It's a valid dataclass field, so it flows end-to-end with no extra plumbing: the model passes it in create_plan_item(spec=...), the store rebuilds it via ImagingSpec(**valid-fields), and it round-trips through serialization. Backend only; needs a server restart to load the new prompt. The actual inference behaviour is validated live in plan mode (see handoff). Co-Authored-By: Claude Fable 5 --- gently/harness/memory/model.py | 5 +++++ gently/harness/plan_mode/prompt.py | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index 2a164176..58c8ee84 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -240,6 +240,11 @@ class ImagingSpec: success_criteria: str | None = None comparison_to: str | None = None # "Compare to WT session 1" + # Per-field provenance for INFERRED values — field name -> {source, confidence}. + # e.g. {"laser_wavelength_nm": {"source": "inferred:genotype", "confidence": "medium"}} + # Lets the UI tag each value with where it came from and what to confirm. + provenance: Dict[str, Dict[str, str]] = field(default_factory=dict) + @dataclass class BenchSpec: diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index 4228249c..62df77d7 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -21,8 +21,17 @@ 6. Challenge assumptions — suggest controls the researcher might not have thought of 7. Suggest experiments outside of imaging where appropriate (bench assays, genetics, analysis) -DO NOT rush to a plan. Gather information first. Ask questions. Search the literature. -Understand the researcher's goals and constraints before proposing. +Work INFERENCE-FIRST: arrive with a draft, don't interrogate. Infer what you +reasonably can — read the reporters in the strain's genotype and set the +excitation wavelengths from your knowledge of fluorophore spectra (e.g. +TagRFP/mCherry ≈ 561 nm, GFP/GCaMP ≈ 488 nm), let the organism set sensible +defaults, and let lab/campaign context fill the rest. Record each inferred +value's source and confidence in the imaging spec's ``provenance``. State a +wavelength only when you're confident; if a reporter is unfamiliar or ambiguous, +mark it low-confidence and confirm via ask_user_choice rather than guessing a +number. Then surface the draft for review, asking ONLY for genuine gaps, +low-confidence guesses, or consequential choices. Search the literature to +confirm, not to stall. ## How to Design an Experimental Plan @@ -115,8 +124,12 @@ PLAN_MODE_GUIDELINES = """\ # Behavior in Plan Mode -1. **Ask before assuming**: Don't assume the researcher's constraints. Ask about - available strains, timeline, equipment access, collaborators. +1. **Infer, then confirm — don't interrogate**: Fill what you can from the strain + genotype, organism defaults, and lab/campaign context, and record where each + value came from (database citation, or your own fluorophore/biology knowledge) + in the spec's ``provenance``. Ask — via ask_user_choice — only for genuine + gaps, low-confidence guesses, or consequential choices, not for things you can + derive or look up. 2. **Think about the full story**: What would reviewers want to see? What controls would strengthen the claims? 3. **Be realistic about timelines**: Genetic crosses take weeks. Behavioral assays From e9475060eaddf149628ba69774c029ab3dc606ac Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 04:46:29 +0530 Subject: [PATCH 006/200] UX v2 Phase 3b: surface inferred imaging spec + per-field provenance in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Phase 3a's inference visible. renderSpec now shows the channel (laser_wavelength_nm) and reporter/genotype rows — which it omitted before — and tags each value with its provenance ("561 nm · inferred · medium") read from spec.provenance, so the researcher can see what was inferred vs. cited and what to confirm. - agent-chat.js renderSpec: keyed rows (label, value, fieldKey) with a small source/confidence tag per row when spec.provenance carries that field; adds Genotype/Reporter/Channel rows. - bridge.py: the spec payload builder now includes genotype/reporter/ laser_wavelength_nm and the provenance map (was a curated subset that dropped the channel), so the UI has the data to render. - ask-stage.css: styling for the .ac-spec-src provenance tag. Frontend + a contained backend payload enrichment; node --check + bridge import verified. Note: this surfaces provenance wherever the spec panel is shown and in the plan document (which already serializes provenance); threading it through every spec-emission path (e.g. the apply_plan_acquisition_spec stash) and a full plan_confirm ask with inline edit/confirm is the remaining 3b polish. Co-Authored-By: Claude Fable 5 --- gently/harness/bridge.py | 8 ++++++ gently/ui/web/static/css/ask-stage.css | 14 +++++++++++ gently/ui/web/static/js/agent-chat.js | 34 +++++++++++++++++++------- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index ddbfa9ca..fdd21040 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -251,9 +251,12 @@ def _candidate_to_option(self, item, spec, campaign) -> dict: spec_dict: dict[str, Any] = {} for field in ( "strain", + "genotype", + "reporter", "temperature_c", "num_slices", "exposure_ms", + "laser_wavelength_nm", "interval_s", "stop_condition", "success_criteria", @@ -261,6 +264,11 @@ def _candidate_to_option(self, item, spec, campaign) -> dict: val = getattr(spec, field, None) if val is not None: spec_dict[field] = val + # Carry per-field provenance so the UI can tag inferred values + # (e.g. "561 nm · inferred · medium") and show what to confirm. + prov = getattr(spec, "provenance", None) + if prov: + spec_dict["provenance"] = prov if spec_dict: meta["spec"] = spec_dict diff --git a/gently/ui/web/static/css/ask-stage.css b/gently/ui/web/static/css/ask-stage.css index d706a05c..dc15c623 100644 --- a/gently/ui/web/static/css/ask-stage.css +++ b/gently/ui/web/static/css/ask-stage.css @@ -40,3 +40,17 @@ background: var(--accent, #2f6df6); color: #fff; border-radius: 8px; padding: 8px 12px; line-height: 1; } + +/* Per-field provenance tag on imaging-spec rows (Phase 3b): shows where an + inferred value came from, e.g. "inferred · medium". */ +.ac-spec-src { + margin-left: 6px; + font-size: 10px; + letter-spacing: .02em; + color: var(--text-muted, #94a3b8); + background: var(--bg-hover, #f1f5f9); + border-radius: 999px; + padding: 1px 7px; + white-space: nowrap; + vertical-align: middle; +} diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index c032e67a..c55c358a 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -483,19 +483,35 @@ const AgentChat = (() => { } function renderSpec(spec) { + const prov = spec.provenance || {}; const rows = []; - const add = (k, v) => { if (v !== undefined && v !== null && v !== '') rows.push([k, v]); }; - add('Strain', spec.strain); - add('Temperature', spec.temperature_c != null ? `${spec.temperature_c} °C` : null); - add('Slices', spec.num_slices); - add('Exposure', spec.exposure_ms != null ? `${spec.exposure_ms} ms` : null); - add('Interval', spec.interval_s != null ? `${spec.interval_s} s` : null); - add('Stop at', spec.stop_condition); + // (label, value, fieldKey) — fieldKey ties a row to its provenance entry. + const add = (label, value, key) => { + if (value === undefined || value === null || value === '') return; + rows.push({ label, value, src: key ? prov[key] : null }); + }; + add('Strain', spec.strain, 'strain'); + add('Genotype', spec.genotype, 'genotype'); + add('Reporter', spec.reporter, 'reporter'); + add('Channel', spec.laser_wavelength_nm != null ? `${spec.laser_wavelength_nm} nm` : null, 'laser_wavelength_nm'); + add('Temperature', spec.temperature_c != null ? `${spec.temperature_c} °C` : null, 'temperature_c'); + add('Slices', spec.num_slices, 'num_slices'); + add('Exposure', spec.exposure_ms != null ? `${spec.exposure_ms} ms` : null, 'exposure_ms'); + add('Interval', spec.interval_s != null ? `${spec.interval_s} s` : null, 'interval_s'); + add('Stop at', spec.stop_condition, 'stop_condition'); if (!rows.length) return; + // A small "where did this come from" tag for inferred values. + const srcTag = (src) => { + if (!src || !src.source) return ''; + const where = String(src.source).split(':')[0]; + const conf = src.confidence ? ` · ${src.confidence}` : ''; + const title = escapeHtml(String(src.source) + (src.confidence ? ` (confidence: ${src.confidence})` : '')); + return ` ${escapeHtml(where + conf)}`; + }; const el = document.createElement('div'); el.className = 'ac-spec'; - el.innerHTML = '
Imaging spec applied
' + - rows.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join(''); + el.innerHTML = '
Imaging spec
' + + rows.map(r => `
${escapeHtml(r.label)}${escapeHtml(r.value)}${srcTag(r.src)}
`).join(''); log.appendChild(el); scrollToBottom(); } From 2387225d30614a70954fc5ae47594bff74ef9042 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 05:28:05 +0530 Subject: [PATCH 007/200] UX v2 Phase 4: co-editable shared-visibility surface (the agent's mind, live) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the agent's expectations (beliefs), watchpoints (attention), and open questions (uncertainty) as a calm panel on the landing, updating live and resolvable by the control holder. The "store has no event bus" blocker is solved via the EXISTING global bus rather than dependency injection (matches agent.py's `emit` usage, so no __init__/launch_gently changes): - core/event_bus.py: new EventType.CONTEXT_UPDATED. - file_store.py: FileContextStore._notify_context_change() emits it (lazy import, best-effort) from add/resolve of expectations, watchpoints, and questions. The server already broadcasts ALL bus events to /ws (subscribe_async("*")), and websocket.js re-emits them on ClientEventBus — so the surface refreshes live with zero new transport. Verified: the emit fires on the bus (unit check). - routes/context.py (new, registered): GET /api/context (read the 3 lenses, defensive on cold start) + POST .../{id}/resolve for questions/watchpoints/ expectations, each gated by Depends(require_control) (data.py pattern, NOT the mesh-scoped campaigns auth) so viewers can't mutate the agent's mind. Read side reuses campaigns._serialize. - context-surface.js (new, ux_v2 only): fetches /api/context, renders the three lenses, re-fetches on CONTEXT_UPDATED + AGENT_CONTROL, and lets the holder answer a question (inline input — no native prompt), resolve a watchpoint, or confirm an expectation. shell.css: scoped styling. index.html: panel mounted at the top of the home landing under {% if ux_v2 %}. Backend needs a server restart to load; live push + render is validated in-app. Proactive #ask-stage cards from watchpoint creation are the remaining Phase-4 polish (noted). Co-Authored-By: Claude Fable 5 --- gently/core/event_bus.py | 4 + gently/harness/memory/file_store.py | 15 +++ gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/context.py | 71 ++++++++++++++ gently/ui/web/static/css/shell.css | 25 +++++ gently/ui/web/static/js/context-surface.js | 105 +++++++++++++++++++++ gently/ui/web/templates/index.html | 2 + 7 files changed, 224 insertions(+) create mode 100644 gently/ui/web/routes/context.py create mode 100644 gently/ui/web/static/js/context-surface.js diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 3b98e616..09edd389 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -92,6 +92,10 @@ class EventType(Enum): # gently/core/log_bridge.py — opt-in handler. LOG_RECORD = auto() + # Agent context/mind updates (expectations / watchpoints / questions) — + # drives the shared-visibility surface in the v2 UI. + CONTEXT_UPDATED = auto() + # Operator-action events. Distinct from EMBRYOS_UPDATE because they # carry intent ("a human did this") rather than just state delta. # Candidate orchestrators can subscribe and reason about what the diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 0531dcce..3672ba88 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1908,6 +1908,15 @@ def get_observations_for_embryo(self, embryo_id: str, limit: int = 20) -> list[O # Expectations # ================================================================== + def _notify_context_change(self, kind: str = "context") -> None: + """Emit CONTEXT_UPDATED on the global bus so the shared-visibility + surface refreshes live. Best-effort — a bus failure never breaks a write.""" + try: + from gently.core.event_bus import emit, EventType + emit(EventType.CONTEXT_UPDATED, {"kind": kind}, source="context_store") + except Exception: + pass + def add_expectation(self, exp: Expectation): path = self.agent_dir / "active" / "expectations.yaml" items = self._read_yaml(path) or [] @@ -1925,6 +1934,7 @@ def add_expectation(self, exp: Expectation): } ) self._write_yaml(path, items) + self._notify_context_change("expectation") def get_pending_expectations(self) -> list[Expectation]: path = self.agent_dir / "active" / "expectations.yaml" @@ -1956,6 +1966,7 @@ def resolve_expectation(self, exp_id: str, status: ExpectationStatus): item["resolved_at"] = now break self._write_yaml(path, items) + self._notify_context_change("expectation") # ================================================================== # Watchpoints @@ -1975,6 +1986,7 @@ def add_watchpoint(self, wp: Watchpoint): } ) self._write_yaml(path, items) + self._notify_context_change("watchpoint") def get_active_watchpoints(self) -> list[Watchpoint]: path = self.agent_dir / "active" / "watchpoints.yaml" @@ -2002,6 +2014,7 @@ def resolve_watchpoint(self, wp_id: str): item["status"] = "resolved" break self._write_yaml(path, items) + self._notify_context_change("watchpoint") # ================================================================== # Questions @@ -2021,6 +2034,7 @@ def add_question(self, q: Question): } ) self._write_yaml(path, items) + self._notify_context_change("question") def get_open_questions(self) -> list[Question]: path = self.agent_dir / "active" / "questions.yaml" @@ -2042,6 +2056,7 @@ def resolve_question(self, q_id: str, resolution: str): item["resolved_at"] = now break self._write_yaml(path, items) + self._notify_context_change("question") # ================================================================== # Learnings diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index ebd90770..bdbf3db7 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -10,6 +10,7 @@ from .auth_routes import create_router as create_auth_router from .campaigns import create_router as create_campaigns_router from .chat import create_router as create_chat_router +from .context import create_router as create_context_router from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .images import create_router as create_images_router @@ -33,6 +34,7 @@ def register_all_routes(server): create_websocket_router, create_agent_ws_router, create_chat_router, + create_context_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/context.py b/gently/ui/web/routes/context.py new file mode 100644 index 00000000..2d7c39df --- /dev/null +++ b/gently/ui/web/routes/context.py @@ -0,0 +1,71 @@ +"""Context (shared-visibility) routes. + +Exposes the agent's "mind" — its open questions (uncertainty), active +watchpoints (attention), and pending expectations (beliefs) — read by anyone, +resolvable only by the control holder. Live updates ride the CONTEXT_UPDATED +event the FileContextStore emits on the global bus, which the server already +broadcasts to /ws; the client just re-fetches /api/context on it (no polling). +""" + +from fastapi import APIRouter, Body, Depends + +from gently.ui.web.auth import require_control +from .campaigns import _serialize + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _store(): + # Defensive: the store is wired after construction; tolerate cold start. + return getattr(server, "context_store", None) + + @router.get("/api/context") + async def get_context(): + cs = _store() + empty = {"available": False, "expectations": [], "watchpoints": [], "questions": []} + if cs is None: + return empty + try: + return { + "available": True, + "questions": [_serialize(q) for q in cs.get_open_questions()], + "watchpoints": [_serialize(w) for w in cs.get_active_watchpoints()], + "expectations": [_serialize(e) for e in cs.get_pending_expectations()], + } + except Exception: + return empty + + @router.post("/api/context/questions/{q_id}/resolve", + dependencies=[Depends(require_control)]) + async def resolve_question(q_id: str, resolution: str = Body("", embed=True)): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + cs.resolve_question(q_id, resolution or "") + return {"ok": True} + + @router.post("/api/context/watchpoints/{wp_id}/resolve", + dependencies=[Depends(require_control)]) + async def resolve_watchpoint(wp_id: str): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + cs.resolve_watchpoint(wp_id) + return {"ok": True} + + @router.post("/api/context/expectations/{exp_id}/resolve", + dependencies=[Depends(require_control)]) + async def resolve_expectation(exp_id: str, status: str = Body("confirmed", embed=True)): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + from gently.harness.memory.model import ExpectationStatus + try: + st = ExpectationStatus(status) + except ValueError: + st = ExpectationStatus.CONFIRMED + cs.resolve_expectation(exp_id, st) + return {"ok": True} + + return router diff --git a/gently/ui/web/static/css/shell.css b/gently/ui/web/static/css/shell.css index 7501572f..211be553 100644 --- a/gently/ui/web/static/css/shell.css +++ b/gently/ui/web/static/css/shell.css @@ -77,3 +77,28 @@ body.ux-v2 .app-main { animation: v2-rise .5s ease backwards .1s; } @media (prefers-reduced-motion: reduce) { body.ux-v2 .v2-rail, body.ux-v2 .v2-strip, body.ux-v2 .app-main { animation: none; } } + +/* ── Shared-visibility surface (the agent's view) ──────────── */ +body.ux-v2 .cx-surface { + margin: 0 0 16px; + border: 1px solid var(--border, #e4e9f0); + border-radius: 14px; + background: var(--bg-card, #fff); + padding: 14px 16px; +} +body.ux-v2 .cx-surface.hidden { display: none; } +body.ux-v2 .cx-title { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 8px; } +body.ux-v2 .cx-lens { margin-bottom: 10px; } +body.ux-v2 .cx-lens-h { font-size: 11px; font-weight: 600; color: var(--text-secondary, #475569); margin: 6px 0 4px; } +body.ux-v2 .cx-item { display: flex; align-items: center; gap: 9px; padding: 5px 0; flex-wrap: wrap; } +body.ux-v2 .cx-text { flex: 1; min-width: 0; font-size: 13px; color: var(--text, #0f172a); } +body.ux-v2 .cx-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } +body.ux-v2 .cx-dot.cx-q { background: #d97706; } +body.ux-v2 .cx-dot.cx-w { background: var(--accent, #2f6df6); } +body.ux-v2 .cx-dot.cx-e { background: var(--accent-green, #16a34a); } +body.ux-v2 .cx-act { flex: none; border: 1px solid var(--border, #e4e9f0); background: none; color: var(--text-secondary, #475569); border-radius: 8px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; } +body.ux-v2 .cx-act:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); } +body.ux-v2 .cx-answer { display: flex; gap: 6px; align-items: center; flex: 1 0 100%; margin-top: 4px; } +body.ux-v2 .cx-answer.hidden { display: none; } +body.ux-v2 .cx-answer-input { flex: 1; min-width: 0; border: 1px solid var(--border, #cbd5e1); border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px; } +body.ux-v2 .cx-answer-go { border: 0; background: var(--accent, #2f6df6); color: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; } diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js new file mode 100644 index 00000000..d8c2d0de --- /dev/null +++ b/gently/ui/web/static/js/context-surface.js @@ -0,0 +1,105 @@ +/** + * ContextSurface (ux_v2): renders the agent's "mind" as a calm, always-visible + * panel — open questions (uncertainty), watchpoints (attention), expectations + * (beliefs) — read from /api/context and refreshed live on the CONTEXT_UPDATED + * event (the store emits it; the server broadcasts it to /ws; no polling). + * + * The control holder can resolve items inline (answer a question, resolve a + * watchpoint, confirm an expectation); observers see it read-only. No-ops + * unless #context-surface is present (flag off → v1 untouched). + */ +const ContextSurface = (() => { + let el = null, loading = false; + + const esc = (s) => (typeof escapeHtml === 'function') + ? escapeHtml(String(s == null ? '' : s)) : String(s == null ? '' : s); + const hasControl = () => + (typeof AgentChat !== 'undefined' && AgentChat.hasControl) ? AgentChat.hasControl() : true; + + async function fetchAndRender() { + if (!el || loading) return; + loading = true; + try { render(await (await fetch('/api/context')).json()); } + catch (e) { /* keep last render */ } + finally { loading = false; } + } + + function section(label, html) { return html ? `
${label}
${html}
` : ''; } + + function render(data) { + if (!el) return; + const hc = hasControl(); + const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || []; + if (!questions.length && !watchpoints.length && !expectations.length) { + el.innerHTML = ''; el.classList.add('hidden'); return; + } + el.classList.remove('hidden'); + + const qHtml = questions.map(it => ` +
+ + ${esc(it.content)} + ${hc ? '' : ''} + ${hc ? '' : ''} +
`).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + section('Open questions', qHtml) + section('Watching', wHtml) + section('Expectations', eHtml); + wire(); + } + + function wire() { + el.querySelectorAll('.cx-item').forEach(item => { + const kind = item.dataset.kind, id = item.dataset.id; + const actBtn = item.querySelector('.cx-act'); + if (!actBtn) return; + const act = actBtn.dataset.act; + if (act === 'answer') { + const box = item.querySelector('.cx-answer'); + const input = item.querySelector('.cx-answer-input'); + const submit = () => resolve(kind, id, { resolution: input.value.trim() }); + actBtn.addEventListener('click', () => { box.classList.toggle('hidden'); if (!box.classList.contains('hidden')) input.focus(); }); + item.querySelector('.cx-answer-go').addEventListener('click', submit); + input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); + } else if (act === 'resolve') { + actBtn.addEventListener('click', () => resolve(kind, id, {})); + } else if (act === 'confirm') { + actBtn.addEventListener('click', () => resolve(kind, id, { status: 'confirmed' })); + } + }); + } + + async function resolve(kind, id, body) { + try { + await fetch(`/api/context/${kind}/${encodeURIComponent(id)}/resolve`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), + }); + } catch (e) { /* ignore; surface stays as-is */ } + fetchAndRender(); // CONTEXT_UPDATED also re-fetches for every client + } + + function init() { + el = document.getElementById('context-surface'); + if (!el) return; // ux_v2 off → no-op + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONTEXT_UPDATED', () => fetchAndRender()); + ClientEventBus.on('AGENT_CONTROL', () => fetchAndRender()); // re-render with/without resolve controls + } + fetchAndRender(); + } + + document.addEventListener('DOMContentLoaded', init); + return { refresh: fetchAndRender }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 4bcf99c0..9a4ff9dd 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -59,6 +59,7 @@
+ {% if ux_v2 %}{% endif %}

Welcome to Gently

@@ -670,5 +671,6 @@

Properties

+ From e8ce2bfd0498a1188f1d1034025c43b9a658e5fc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 05:33:42 +0530 Subject: [PATCH 008/200] UX v2 Phase 4 follow-up: show an empty-state for the agent's-view panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context surface hid itself entirely when the agent had no expectations/ watchpoints/questions — so on a fresh session it was invisible and read as "missing". Render a calm "nothing yet" empty-state instead, so the surface is discoverable before the agent has formed any beliefs. Static JS/CSS only — hard-reload to pick it up, no restart. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/css/shell.css | 1 + gently/ui/web/static/js/context-surface.js | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/static/css/shell.css b/gently/ui/web/static/css/shell.css index 211be553..c38ae89a 100644 --- a/gently/ui/web/static/css/shell.css +++ b/gently/ui/web/static/css/shell.css @@ -102,3 +102,4 @@ body.ux-v2 .cx-answer { display: flex; gap: 6px; align-items: center; flex: 1 0 body.ux-v2 .cx-answer.hidden { display: none; } body.ux-v2 .cx-answer-input { flex: 1; min-width: 0; border: 1px solid var(--border, #cbd5e1); border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px; } body.ux-v2 .cx-answer-go { border: 0; background: var(--accent, #2f6df6); color: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; } +body.ux-v2 .cx-empty { font-size: 12.5px; color: var(--text-muted, #94a3b8); font-style: italic; padding: 2px 0 4px; } diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js index d8c2d0de..2e10000d 100644 --- a/gently/ui/web/static/js/context-surface.js +++ b/gently/ui/web/static/js/context-surface.js @@ -30,10 +30,14 @@ const ContextSurface = (() => { if (!el) return; const hc = hasControl(); const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || []; + el.classList.remove('hidden'); if (!questions.length && !watchpoints.length && !expectations.length) { - el.innerHTML = ''; el.classList.add('hidden'); return; + // Show an empty-state rather than vanishing, so the surface is + // discoverable before the agent has formed any beliefs. + el.innerHTML = '
Agent’s view
' + + '
Nothing yet — the agent’s expectations, watchpoints, and open questions appear here as it works.
'; + return; } - el.classList.remove('hidden'); const qHtml = questions.map(it => `
From c98a33fd9ad980d0760908e6ece34a752a728b9e Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 05:38:44 +0530 Subject: [PATCH 009/200] Put the free-the-port command in the "port in use" error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the viz server can't bind its port, the error now tells you how to free it (fuser -k /tcp, or lsof -ti | xargs kill) instead of just "close it first" — uses self.port so it's always the right port. (Standalone DX fix.) Co-Authored-By: Claude Fable 5 --- gently/ui/web/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index c16c548d..faf2a2d8 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -788,9 +788,9 @@ async def on_start(self): sock.bind((self.host, self.port)) except OSError: raise OSError( - f"Port {self.port} is already in use. " - "Is another instance of the agent running? " - "Close it first and try again." + f"Port {self.port} is already in use — another instance may be running. " + f"Free it with: fuser -k {self.port}/tcp " + f"(or: lsof -ti:{self.port} | xargs -r kill), then try again." ) from None finally: sock.close() From 927900ada57471306cd8d970de7f95f4c5bbd11e Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 05:47:24 +0530 Subject: [PATCH 010/200] =?UTF-8?q?UX=20v2=20Phase=205:=20Experiment=20vie?= =?UTF-8?q?w=20shows=20real=20tactics=20only=20=E2=80=94=20drop=20stubbed?= =?UTF-8?q?=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Experiment view's job is to show the live experimental TACTIC patterns (cadence: base/fast/burst/cooldown + reactive-monitoring rules). It was falling back to a ~130-line STUB_STRATEGY (and a "mockup · stubbed data" badge) whenever there was no active experiment or the fetch wasn't ready — i.e. production could render fake tactics. - Removed the STUB_STRATEGY const entirely. - loadStrategy() now returns null on non-OK / error (no stub fallback). - render(null) shows a calm empty state ("No active experiment — the imaging tactics will appear here once a run is live"), never fabricated data. - Removed both "mockup · stubbed data" badges; the header just shows "live". Affects v1 and v2 (the Experiment tab isn't flag-gated) — removing fake data is correct for both; it only changes the no-active-experiment case (stub → empty state), real live runs render as before. node --check clean. NOT done (deliberately): the large carve of a new per-embryo renderer out of the 4,556-line embryos.js — the existing ExperimentOverview already renders the tactic patterns from /api/experiments/current/strategy, and the detailed contents are yours to define. The reconcileWithServerState/clearAllState contract in embryos.js is left untouched. Co-Authored-By: Claude Fable 5 --- .../ui/web/static/js/experiment-overview.js | 176 ++---------------- 1 file changed, 19 insertions(+), 157 deletions(-) diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 33032a40..9e7fc221 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -1,147 +1,12 @@ /** - * Experiment Overview Tab — vector-graphics view of the planned timelapse. + * Experiment Overview Tab — vector-graphics view of the live imaging tactics + * (cadence patterns + reactive-monitoring rules) for the running experiment. * - * Data source priority: - * 1. GET /api/experiments/current/strategy — live snapshot from FileStore. - * 2. STUB_STRATEGY below — used when the live fetch - * fails or no session exists. - * - * The render path is data-shape-driven and doesn't care which source the - * snapshot came from — only ``ExperimentOverview.isLive`` differs so the - * header badge can say "live" or "mockup · stubbed data". + * Data source: GET /api/experiments/current/strategy — the live snapshot from + * FileStore. When there is no active experiment (or the fetch isn't ready), the + * view shows a calm empty state; it never renders stubbed/mock data. */ -const STUB_STRATEGY = { - session_id: "20260522_1430_dopaminergic_demo_a3f8e1c2", - session_name: "dopaminergic-reporter demo", - started_at: "2026-05-22T14:30:00", - now_offset_s: 8100, // 2h 15min into the run - horizon_s: 14400, // 4h total view window (past + projected) - base_interval_s: 120, - dose_budget_base_ms: 50000, - per_timepoint_ms: 500, // 50 slices × 10ms - monitoring_modes: [ - { - name: "expression_monitoring", - description: "Anticipating fluorescent-reporter onset on Test embryos: accelerate to 60s on signal, ramp 488 down on saturation.", - applies_to_roles: ["test"], - params: { - fast_interval: 60, - rampdown_step_pct: 1.0, - rampdown_floor_pct: 2.0, - rampdown_ceiling_pct: 6.0 - } - }, - { - name: "pre_terminal_monitoring", - description: "Anticipating organism pre-terminal stage (pretzel): accelerate to 30s on detection.", - applies_to_roles: ["test"], - params: { fast_interval: 30 } - } - ], - triggers: [ - { id: "t1", kind: "interval_rule", label: "signal onset", - when_text: "dopaminergic ≥ WEAK", then_text: "120s → 60s", - applies_to: ["test"], one_time: true }, - { id: "t2", kind: "power_rule", label: "488 ramp down", - when_text: "intensity = SATURATING (×3)", then_text: "488 ↓ 1%/step, floor 2%", - applies_to: ["test"] }, - { id: "t3", kind: "burst", label: "structure-triggered burst", - when_text: "structure_quality = GOOD", then_text: "burst 200 frames @ 20 Hz", - applies_to: ["test"] }, - { id: "t4", kind: "interval_rule", label: "pre-terminal speedup", - when_text: "stage = pretzel", then_text: "60s → 30s", - applies_to: ["test"], one_time: true } - ], - embryos: [ - { - id: "E1", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 12500, dose_budget_ms: 50000, - tp_acquired: 25, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 3.0, - phases: [ - { mode: "base", start: 0, end: 1800, cadence_s: 120 }, - { mode: "fast", start: 1800, end: 3600, cadence_s: 60 }, - { mode: "burst", start: 3600, end: 3610, frames: 200, hz: 20 }, - { mode: "cooldown", start: 3610, end: 3640, cadence_s: 60 }, - { mode: "fast", start: 3640, end: 8100, cadence_s: 60 } - ], - trigger_events: [ - { trigger_id: "t1", at: 1800 }, - { trigger_id: "t3", at: 3600 }, - { trigger_id: "t2", at: 5400, count: 3 } - ], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 5400, pct: 4.0 }, - { at: 5460, pct: 3.0 }, - { at: 8100, pct: 3.0 } - ], - // Future projection at current cadence (60s, fast). Hatching not - // deterministic so projected_end_s is null — render fades to ∞. - projected_cadence_s: 60, - projected_end_s: null - }, - { - id: "E2", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 6500, dose_budget_ms: 50000, - tp_acquired: 13, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - }, - { - id: "E3", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 38000, dose_budget_ms: 50000, - tp_acquired: 76, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - // Projected dose-exhaust horizon = 4.0h from now (warning condition) - projected_cadence_s: 120, - projected_end_s: null, - dose_exhaust_at_s: 12000 // budget will run out at this elapsed time - }, - { - id: "C1", role: "calibration", color: "#22d3ee", icon: "◆", - dose_used_ms: 33500, dose_budget_ms: 500000, // 10× multiplier - tp_acquired: 67, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - } - ] -}; const ExperimentOverview = { initialized: false, @@ -164,23 +29,19 @@ const ExperimentOverview = { cache: 'no-store' }); if (!resp.ok) { - console.warn( - '[ExperimentOverview] strategy fetch returned', - resp.status, '- falling back to stub' - ); + // No active experiment / not ready yet — show the empty state, + // never stubbed data. + console.warn('[ExperimentOverview] strategy fetch returned', resp.status); this.isLive = false; - return STUB_STRATEGY; + return null; } const data = await resp.json(); this.isLive = true; return data; } catch (e) { - console.warn( - '[ExperimentOverview] strategy fetch error - falling back to stub:', - e - ); + console.warn('[ExperimentOverview] strategy fetch error:', e); this.isLive = false; - return STUB_STRATEGY; + return null; } }, @@ -193,7 +54,7 @@ const ExperimentOverview = { }); // Re-render against the last fetched strategy (no re-fetch on tab // switch — refresh happens on tab activation in the bootstrap). - this.render(this.activeStrategy || STUB_STRATEGY); + this.render(this.activeStrategy); }, render(s) { @@ -204,6 +65,12 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); + if (!s) { + // No active experiment — a calm empty state, never stubbed data. + root.innerHTML = '
' + + 'No active experiment — the imaging tactics (cadence, reactive rules) will appear here once a run is live.
'; + return; + } try { root.innerHTML = ''; if (this.activeView === 'rules') { @@ -301,7 +168,6 @@ const ExperimentOverview = { const metaRow = el('div', 'expov-header-row expov-header-row-meta'); metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); metaRow.appendChild(elText('span', 'expov-session-id', s.session_id)); - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); header.appendChild(metaRow); root.appendChild(header); @@ -330,11 +196,7 @@ const ExperimentOverview = { if (s.session_name && s.session_name !== s.session_id) { metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); } - if (this.isLive) { - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - } else { - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); - } + metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); wrap.appendChild(metaRow); // Compact key-metric strip From f69bc4e881da74910ac63ea06a5104fd0eb01bad Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 05:54:17 +0530 Subject: [PATCH 011/200] UX v2 Phase 6 (step 1): flip GENTLY_UX_V2 default ON, keep v1 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 is now the default UI — no env var needed. The v1 dashboard stays reachable as a fallback via GENTLY_UX_V2=0 (and its markup is NOT deleted yet). This is the reversible "flip → soak" step; the irreversible v1 markup/CSS deletion is deferred until v2 has run as the default and is confirmed good. Co-Authored-By: Claude Fable 5 --- gently/settings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gently/settings.py b/gently/settings.py index 8b16f871..d197bc0d 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -124,10 +124,10 @@ class TransferSettings: class UISettings: """Web UI feature flags.""" # New agent-first UX paradigm (welcome→shell unfold, dual-rendered agent - # asks, inference-first plan mode, shared-visibility surface). Off by - # default; flip per-deployment via GENTLY_UX_V2=1 while the migration - # soaks behind the flag. The v1 dashboard stays the default until then. - ux_v2: bool = field(default_factory=lambda: _env("UX_V2", False)) + # asks, inference-first plan mode, shared-visibility surface). Now ON by + # default; the v1 dashboard remains available as a fallback via + # GENTLY_UX_V2=0 until the v1 markup is removed in a later cleanup step. + ux_v2: bool = field(default_factory=lambda: _env("UX_V2", True)) @dataclass(frozen=True) From e4de3a117449b9214cd2399c9c7b6295b78a01a4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 07:39:10 +0530 Subject: [PATCH 012/200] =?UTF-8?q?UX=20v2:=20agent-first=20landing=20?= =?UTF-8?q?=E2=86=92=20in-page=20plan=20wizard=20(flag-gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the entry landing the prototype sketched — agent orb, time-aware greeting, and choice cards (Plan / quick look / free-text escape), behind GENTLY_UX_V2 and receding into the workspace. Crucially, the plan dialogue renders IN the landing, not the chat REPL: 'Plan an experiment' switches to an in-place plan-wizard screen, enters /plan, and renders the agent's ask_user_choice questions as button cards there (reusing AgentChat.buildAskCard), with the plan assembling from each pick. agent-chat.js: runCommand is now connection-aware (connect + queue/flush on open) so the page drives the agent without opening the chat panel. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/css/landing.css | 227 ++++++++++++++++++++++++++ gently/ui/web/static/js/agent-chat.js | 18 +- gently/ui/web/static/js/landing.js | 214 ++++++++++++++++++++++++ gently/ui/web/templates/index.html | 82 ++++++++++ 4 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 gently/ui/web/static/css/landing.css create mode 100644 gently/ui/web/static/js/landing.js diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css new file mode 100644 index 00000000..c5152cf5 --- /dev/null +++ b/gently/ui/web/static/css/landing.css @@ -0,0 +1,227 @@ +/* ux_v2 landing — the agent-first welcome that the prototype sketched, ported + into production. A full-bleed overlay shown on first entry that recedes into + the workspace once the user picks a path. Everything is scoped under + body.ux-v2 and the #v2-landing node only renders when the flag is on, so v1 + is byte-for-byte untouched. Visual language mirrors ux-prototype/landing.html + but reuses production's CSS variables (with the prototype hexes as fallback) + so it tracks the app theme. */ + +.v2-landing { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow: hidden; + background: + radial-gradient(1100px 700px at 78% -8%, #eaf1ff 0%, transparent 55%), + radial-gradient(900px 600px at 8% 108%, #eafaf0 0%, transparent 55%), + var(--bg, #f6f8fb); + transition: opacity .5s cubic-bezier(.22,1,.36,1), transform .5s cubic-bezier(.22,1,.36,1), visibility .5s; +} +/* The calm screen "unfolds" into the workspace: fade + slight scale-up, then + the node is pulled from the layout (display:none set by JS after the + transition) so it never traps clicks. */ +.v2-landing.dismissed { + opacity: 0; + visibility: hidden; + transform: scale(1.015); + pointer-events: none; +} +.v2-landing::before { + content: ""; + position: absolute; + inset: -20vmax; + background: radial-gradient(closest-side, rgba(47,109,246,.10), transparent 70%); + filter: blur(30px); + animation: v2land-drift 26s cubic-bezier(.22,1,.36,1) infinite alternate; + pointer-events: none; +} +@keyframes v2land-drift { + 0% { transform: translate(-6vw,-4vh) scale(1); } + 100% { transform: translate(8vw,6vh) scale(1.15); } +} + +.v2-landing-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + max-width: 760px; + width: 100%; +} +.v2-landing-rise { animation: v2land-rise .6s cubic-bezier(.22,1,.36,1) backwards; } +.v2-landing-rise[data-i="1"] { animation-delay: .07s; } +.v2-landing-rise[data-i="2"] { animation-delay: .14s; } +.v2-landing-rise[data-i="3"] { animation-delay: .21s; } +@keyframes v2land-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } + +/* agent presence */ +.v2-landing-agent { display: flex; flex-direction: column; align-items: center; gap: 16px; } +.v2-landing-orb { + width: 52px; height: 52px; border-radius: 50%; + background: radial-gradient(closest-side at 38% 34%, #ffffff, #bcd3ff 40%, var(--accent, #2f6df6) 100%); + box-shadow: 0 6px 22px rgba(47,109,246,.45), inset 0 0 12px rgba(255,255,255,.6); + animation: v2land-breathe 4s ease-in-out infinite; +} +@keyframes v2land-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } } +.v2-landing-say { + font-size: clamp(20px, 3vw, 28px); font-weight: 600; letter-spacing: -.02em; + text-align: center; max-width: 22ch; line-height: 1.25; color: var(--text, #0f172a); +} +.v2-landing-say .dim { color: var(--text-muted, #94a3b8); font-weight: 500; } + +/* choice cards */ +.v2-landing-choices { + display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 18px; + margin-top: 34px; width: min(720px, 92vw); +} +@media (max-width: 620px) { .v2-landing-choices { grid-template-columns: 1fr; } } +.v2-choice { + text-align: left; cursor: pointer; position: relative; overflow: hidden; + border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); + border-radius: 18px; padding: 22px 22px 20px; + box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); + font: inherit; color: var(--text, #0f172a); + transition: transform .26s cubic-bezier(.22,1,.36,1), box-shadow .26s cubic-bezier(.22,1,.36,1), border-color .26s; +} +.v2-choice:hover { + transform: translateY(-4px); border-color: #cfdcf5; + box-shadow: 0 2px 6px rgba(15,23,42,.06), 0 18px 50px rgba(47,109,246,.14); +} +.v2-choice:active { transform: translateY(-1px) scale(.995); } +.v2-choice-ic { + width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; + background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); margin-bottom: 14px; +} +.v2-choice.alt .v2-choice-ic { background: var(--accent-green-soft, #e7f6ec); color: var(--accent-green, #16a34a); } +.v2-choice h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -.01em; } +.v2-choice p { margin: 0; color: var(--text-secondary, #475569); font-size: 13.5px; line-height: 1.5; } +.v2-choice-tag { + position: absolute; top: 16px; right: 16px; + font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #94a3b8); + border: 1px solid var(--border, #e4e9f0); border-radius: 999px; padding: 3px 9px; +} +.v2-choice-go { + margin-top: 16px; display: flex; align-items: center; gap: 6px; + color: var(--accent, #2f6df6); font-size: 13px; font-weight: 600; + opacity: 0; transform: translateX(-4px); transition: .26s cubic-bezier(.22,1,.36,1); +} +.v2-choice.alt .v2-choice-go { color: var(--accent-green, #16a34a); } +.v2-choice:hover .v2-choice-go { opacity: 1; transform: none; } + +/* escape hatch — chat is the last resort, an obvious pill */ +.v2-escape { margin-top: 26px; display: flex; flex-direction: column; align-items: center; } +.v2-escape-toggle { + display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font: inherit; font-size: 13px; + background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + padding: 8px 15px; border-radius: 999px; + box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); + transition: color .2s, border-color .2s, transform .2s cubic-bezier(.22,1,.36,1); +} +.v2-escape-toggle:hover { color: var(--text, #0f172a); border-color: #cfd9e6; transform: translateY(-1px); } +.v2-escape-toggle .v2-escape-caret { display: inline-block; transition: transform .3s cubic-bezier(.22,1,.36,1); opacity: .55; } +.v2-escape.open .v2-escape-toggle .v2-escape-caret { transform: rotate(180deg); } +.v2-escape-field { + display: flex; align-items: center; gap: 8px; width: min(520px, 90vw); + max-height: 0; opacity: 0; overflow: hidden; + transition: max-height .4s cubic-bezier(.22,1,.36,1), opacity .4s cubic-bezier(.22,1,.36,1), margin .4s cubic-bezier(.22,1,.36,1); +} +.v2-escape.open .v2-escape-field { max-height: 64px; opacity: 1; margin-top: 12px; } +.v2-escape-field input { + flex: 1; min-width: 0; border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); + border-radius: 12px; padding: 12px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a); + outline: none; box-shadow: 0 1px 2px rgba(15,23,42,.04); + transition: border-color .2s, box-shadow .2s; +} +.v2-escape-field input:focus { border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(47,109,246,.12); } +.v2-escape-send { + appearance: none; border: 0; cursor: pointer; flex: none; width: 42px; height: 42px; border-radius: 12px; + background: var(--accent, #2f6df6); color: #fff; display: grid; place-items: center; + box-shadow: 0 6px 16px rgba(47,109,246,.35); transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; +} +.v2-escape-send:hover { transform: translateY(-1px); filter: brightness(1.05); } + +/* one-way skip into the workspace (e.g. a reload mid-session) */ +.v2-landing-skip { + margin-top: 28px; background: none; border: 0; cursor: pointer; font: inherit; font-size: 12.5px; + color: var(--text-muted, #94a3b8); padding: 6px 10px; border-radius: 8px; + transition: color .2s; +} +.v2-landing-skip:hover { color: var(--text-secondary, #475569); } + +/* Under ux_v2 the landing IS the welcome moment, so the legacy home hero + (static "Welcome to Gently" + start button) would be a duplicate behind it — + hide it. The recent-* cards and the context surface stay. */ +body.ux-v2 .home-hero { display: none; } + +/* ── Two-screen system: welcome ↔ in-place plan wizard ───────── */ +.v2-landing-inner { max-width: 980px; } /* widen for the plan layout */ +.v2-landing .v2-screen { display: none; width: 100%; } +.v2-landing[data-screen="welcome"] .v2-screen-welcome { + display: flex; flex-direction: column; align-items: center; + max-width: 760px; margin: 0 auto; +} +.v2-landing[data-screen="plan"] .v2-screen-plan { + display: flex; flex-direction: column; + animation: v2land-rise .45s cubic-bezier(.22,1,.36,1) backwards; +} + +/* plan header */ +.v2-plan-head { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.v2-plan-orb { width: 34px; height: 34px; } +.v2-plan-who { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-title { font-size: 18px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); } +.v2-plan-back { + margin-left: auto; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; + color: var(--text-muted, #94a3b8); padding: 6px 10px; border-radius: 8px; transition: color .2s, background .2s; +} +.v2-plan-back:hover { color: var(--text, #0f172a); background: rgba(15,23,42,.05); } + +/* plan body: ask stage + assembling plan */ +.v2-plan-wrap { display: grid; grid-template-columns: 1.35fr .9fr; gap: 20px; align-items: start; } +@media (max-width: 720px) { .v2-plan-wrap { grid-template-columns: 1fr; } } +.v2-plan-main { min-height: 220px; } +.v2-plan-ask:empty { display: none; } +.v2-plan-thinking { display: flex; align-items: center; gap: 9px; color: var(--text-muted, #94a3b8); font-size: 13.5px; padding: 22px 4px; } +.v2-plan-thinking.hidden { display: none; } +.v2-typing { display: inline-flex; gap: 5px; align-items: center; } +.v2-typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #2f6df6); opacity: .4; animation: v2-blink 1.1s infinite; } +.v2-typing i:nth-child(2) { animation-delay: .15s; } +.v2-typing i:nth-child(3) { animation-delay: .3s; } +@keyframes v2-blink { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } } + +.v2-plan-side { + background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); border-radius: 14px; padding: 16px 18px; + box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); +} +.v2-plan-side-h { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 12px; } +.v2-plan-side-empty { color: var(--text-muted, #94a3b8); font-size: 13px; font-style: italic; } +.v2-plan-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 0; border-top: 1px dashed var(--border, #e4e9f0); } +.v2-plan-row:first-child { border-top: 0; } +.v2-plan-row .k { font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-row .v { font-size: 14px; color: var(--text, #0f172a); font-weight: 550; } + +/* plan footer */ +.v2-plan-foot { display: flex; align-items: center; gap: 10px; margin-top: 22px; } +.v2-plan-chat { + margin-right: auto; background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 8px 14px; font: inherit; font-size: 13px; cursor: pointer; transition: border-color .2s, color .2s; +} +.v2-plan-chat:hover { border-color: #cfd9e6; color: var(--text, #0f172a); } +.v2-plan-continue { + background: var(--accent, #2f6df6); color: #fff; border: 0; border-radius: 10px; padding: 10px 16px; + font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; +} +.v2-plan-continue:hover { transform: translateY(-1px); filter: brightness(1.05); } + +@media (prefers-reduced-motion: reduce) { + .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, + .v2-landing[data-screen="plan"] .v2-screen-plan, .v2-typing i { + animation: none !important; + transition-duration: .12s !important; + } +} diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index c55c358a..33dcf2b3 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -12,6 +12,9 @@ const AgentChat = (() => { let ws = null; let reconnectDelay = 1000; + // Commands/messages requested before the socket is open (e.g. from the + // landing, with the chat panel closed) — flushed in order on ws.onopen. + let pendingProgrammatic = []; const MAX_DELAY = 30000; let panelOpen = false; @@ -743,11 +746,17 @@ const AgentChat = (() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } + // Flush programmatic sends queued before the socket opened (see runCommand). + function flushProgrammatic() { + if (!pendingProgrammatic.length) return; + pendingProgrammatic.splice(0).forEach(t => actuallySend(t)); + } + function connect() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; setConn(false, 'Connecting…'); ws = new WebSocket(`${proto}//${location.host}/ws/agent`); - ws.onopen = () => { reconnectDelay = 1000; setConn(true); }; + ws.onopen = () => { reconnectDelay = 1000; setConn(true); flushProgrammatic(); }; ws.onclose = () => { setConn(false, 'Reconnecting…'); setBusy(false); @@ -1067,7 +1076,12 @@ const AgentChat = (() => { function runCommand(text) { if (!text) return; if (!hasControl) { renderControl(); return; } - actuallySend(text); + // Works whether or not the chat panel is open, so the landing can drive + // the agent (enter plan mode) without foregrounding the chat REPL. If the + // socket isn't up yet, queue and connect — it flushes on open. + if (ws && ws.readyState === WebSocket.OPEN) { actuallySend(text); return; } + pendingProgrammatic.push(text); + if (!ws) connect(); } return { togglePanel, runCommand, buildAskCard, answerChoice, hasControl: () => hasControl }; diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js new file mode 100644 index 00000000..35eaf1fa --- /dev/null +++ b/gently/ui/web/static/js/landing.js @@ -0,0 +1,214 @@ +/** + * V2Landing (ux_v2): the agent-first welcome AND the in-place plan wizard. + * + * The key paradigm fix: the plan dialogue renders IN THE LANDING, not the chat + * REPL. Clicking "Plan an experiment" doesn't recede to the dashboard — it + * switches the landing to a plan screen, enters plan mode, and renders the + * agent's ask_user_choice questions as button cards right there (#v2-plan-ask), + * reusing AgentChat.buildAskCard so it's the same card/answer path as elsewhere. + * The plan assembles on the right from each pick. Chat is never the surface + * (it's one click away via "Open conversation" as the last resort). + * + * Plan an experiment → switch to plan screen; /plan + kickoff; asks render here + * Take a quick look → scope (Devices view) + * "just tell me…" → free-text into the agent loop (then chat as transcript) + * + * No-ops unless #v2-landing is present (flag off → v1 untouched, overlay absent). + */ +const V2Landing = (() => { + let el = null; + let current = null; // { reqId, data, isWake } — the ask showing in the plan stage + + const $ = (id) => document.getElementById(id); + + function greet() { + const g = $('v2-landing-greeting'); + if (!g) return; + const h = new Date().getHours(); + const t = h < 5 ? 'Still here.' : h < 12 ? 'Good morning.' + : h < 18 ? 'Good afternoon.' : 'Good evening.'; + g.innerHTML = t + '
What are we doing today?'; + } + + function setScreen(name) { if (el) el.dataset.screen = name; } + function planActive() { + return !!el && el.dataset.screen === 'plan' && !el.classList.contains('dismissed') + && el.style.display !== 'none'; + } + + function dismiss() { + if (!el || el.classList.contains('dismissed')) return; + el.classList.add('dismissed'); + let done = false; + const finish = () => { + if (done) return; + done = true; + el.style.display = 'none'; + el.setAttribute('aria-hidden', 'true'); + }; + el.addEventListener('transitionend', finish, { once: true }); + setTimeout(finish, 650); + } + + // ── plan stage helpers ──────────────────────────────────────────── + function showThinking(on) { + const t = $('v2-plan-thinking'); + if (t) t.classList.toggle('hidden', !on); + } + function clearAsk() { const m = $('v2-plan-ask'); if (m) m.innerHTML = ''; } + function resetSummary() { + const list = $('v2-plan-summary'); + if (list) list.innerHTML = '
Assembling from your choices…
'; + } + function labelFor(data, sel) { + const opts = (data && data.options) || []; + const one = (s) => { + const o = opts.find(o => o && (o.id === s || o.value === s || o.label === s)); + return o ? o.label : String(s); + }; + if (Array.isArray(sel)) return sel.map(one).join(', '); + return one(sel); + } + function recordPick(data, sel) { + const list = $('v2-plan-summary'); + if (!list) return; + const empty = list.querySelector('.v2-plan-side-empty'); + if (empty) empty.remove(); + const row = document.createElement('div'); + row.className = 'v2-plan-row'; + row.innerHTML = ''; + row.querySelector('.k').textContent = (data && data.question) || 'Choice'; + row.querySelector('.v').textContent = labelFor(data, sel); + list.appendChild(row); + } + function renderAsk() { + const mount = $('v2-plan-ask'); + if (!mount || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; + showThinking(false); + const data = current.data, reqId = current.reqId; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId, isWake: current.isWake, hasControl, + onPick: (sel) => { + recordPick(data, sel); + AgentChat.answerChoice(reqId, sel); + current = null; + clearAsk(); + showThinking(true); // agent computing the next step + }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + } + + function startPlan() { + setScreen('plan'); // stay on the overlay; swap welcome → plan wizard + resetSummary(); + clearAsk(); + current = null; + showThinking(true); + // /plan deterministically enters plan mode; the kickoff draws out the + // first question, which the agent asks via ask_user_choice (its prompt + // mandates it) → renders here, not in chat. runCommand connects on its + // own and flushes both in order without opening the chat panel. + if (typeof AgentChat !== 'undefined' && AgentChat.runCommand) { + AgentChat.runCommand('/plan'); + AgentChat.runCommand("Let's design this run — what should it capture?"); + } + } + + function openScope() { + dismiss(); + if (typeof switchTab === 'function') switchTab('devices'); + } + + function sendFreeform(text) { + const v = (text || '').trim(); + dismiss(); // free-text is the chat last-resort → recede, then open chat + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + if (v && AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(v), 300); + } + } + + function init() { + el = $('v2-landing'); + if (!el || typeof ClientEventBus === 'undefined') return; // flag off → no-op + greet(); + + // welcome choices + el.querySelectorAll('[data-landing]').forEach(btn => btn.addEventListener('click', () => { + const kind = btn.dataset.landing; + if (kind === 'plan') startPlan(); + else if (kind === 'standalone') openScope(); + })); + + // welcome escape field + const esc = $('v2-escape'), escToggle = $('v2-escape-toggle'), + escInput = $('v2-escape-input'), escSend = $('v2-escape-send'); + if (escToggle && esc && escInput) { + escToggle.addEventListener('click', () => { + const open = esc.classList.toggle('open'); + if (open) setTimeout(() => escInput.focus(), 120); + }); + const submit = () => sendFreeform(escInput.value); + if (escSend) escSend.addEventListener('click', submit); + escInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + else if (e.key === 'Escape') { e.stopPropagation(); esc.classList.remove('open'); } + }); + } + + const skip = $('v2-landing-skip'); + if (skip) skip.addEventListener('click', dismiss); + + // plan-screen controls + const back = $('v2-plan-back'); + if (back) back.addEventListener('click', () => setScreen('welcome')); + const planChat = $('v2-plan-chat'); + if (planChat) planChat.addEventListener('click', () => { + dismiss(); // chat panel lives under the overlay → recede first + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + setTimeout(() => AgentChat.togglePanel(true), 300); + } + }); + const cont = $('v2-plan-continue'); + if (cont) cont.addEventListener('click', dismiss); + + // The agent's questions render in the plan stage while it's active; once + // we've receded into the workspace, AskStage (#ask-stage) takes over. + ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { + if (!planActive()) return; + current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; + renderAsk(); + }); + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { + if (request_id === '*' || (current && request_id === current.reqId)) { + current = null; + clearAsk(); + if (planActive()) showThinking(true); + } + }); + ClientEventBus.on('AGENT_CONTROL', () => { if (current && planActive()) renderAsk(); }); + + document.addEventListener('keydown', e => { + if (e.key !== 'Escape' || !el || el.classList.contains('dismissed')) return; + if (el.dataset.screen === 'plan') setScreen('welcome'); // step back, don't bail + else dismiss(); + }); + } + + document.addEventListener('DOMContentLoaded', init); + + return { + dismiss, + show: () => { + if (!el) return; + el.style.display = ''; + el.removeAttribute('aria-hidden'); + el.classList.remove('dismissed'); + setScreen('welcome'); + greet(); + }, + }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 9a4ff9dd..c972dc3d 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -17,8 +17,89 @@ + + {% if ux_v2 %} + {# Agent-first welcome — shown on entry, recedes into the workspace once the + user picks a path. Chat is the last resort (the escape pill), not the + first thing. Only rendered when the flag is on. #} +
+
+ + {# ── Screen 1: welcome ── #} +
+
+
+
Hello.
What are we doing today?
+
+ +
+ + + +
+ +
+ +
+ + +
+
+ + +
+ + {# ── Screen 2: the plan wizard, hosted IN the landing. The agent's + ask_user_choice questions render here as button cards (#v2-plan-ask), + NOT in the chat panel. The plan assembles on the right as you pick. #} +
+
+ +
+
Gently · planning
+
Let's design your run
+
+ +
+
+
+
+
thinking through the next step…
+
+ +
+
+ + +
+
+ +
+
+ {% endif %} {% include '_header.html' %} {% include '_navbar.html' %} +
+
+
+
Good evening.
What are we doing today?
+
+ +
+ + + +
+ +
+ +
+ + +
+
+
+ + +
+
+
+
+
+ Gently + +
+
+
+
+
+ + +
+
+
+
+
+ +
+

The plan

+

assembling from your choices

+
+
Nothing yet — pick above and watch it fill in.
+
+
+
+
+
+ + +
+
+
+

Quick look

+

I'll take one careful volume right where the stage is now — nothing scheduled, nothing committed.
(We'll design this surface next — it's a stub for now.)

+
+
+
+ + +
+
+
+ LIVE + run + + 0h 12m elapsed · next 1:43 +
+ + + +
+
+
⚠ Needs you
+
Embryo 3 has been quiet for 40 min — past its expected division window. Keep waiting, or flag it for you?
+
+ + + +
+
+ +

Embryos · 3 tracked

representative — the embryo-wise tactical view is yours to define
+
+
Embryo 1on track
4-cell · imaging normally
+
Embryo 2dividing
sped up to every 30 s
+
Embryo 3stalled
40 min quiet · flagged above
+
+
+
+
+ +
+
+ + + + From 6c6bc7095ab51719521ce6ff29c3be35d9e23344 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 07:39:28 +0530 Subject: [PATCH 014/200] Detectors: forced-tool structured output for hatching + verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert hatching.py and the verifier's four challenger strategies from JSON-in-prose / startswith-scraping with silent defaults to forced tool_choice — the verdict arrives as a validated dict on the tool_use block. Deletes the regex/parse layer; downstream vote-tally/consensus is untouched. Also drop self-rated confidence from these schemas (a heuristics-era artifact — the boolean/categorical judgment is the signal); the ensemble's derived agreement ratio stays. docs/HEURISTICS-AUDIT.md ranks the remaining candidates and the keep-deterministic boundary. Co-Authored-By: Claude Fable 5 --- docs/HEURISTICS-AUDIT.md | 112 ++++ gently/app/detectors/hatching.py | 124 +++-- gently/harness/detection/verifier.py | 758 +++++++++++---------------- 3 files changed, 488 insertions(+), 506 deletions(-) create mode 100644 docs/HEURISTICS-AUDIT.md diff --git a/docs/HEURISTICS-AUDIT.md b/docs/HEURISTICS-AUDIT.md new file mode 100644 index 00000000..249b27cc --- /dev/null +++ b/docs/HEURISTICS-AUDIT.md @@ -0,0 +1,112 @@ +# Heuristics audit — where to use the model (as a typed-output function) instead + +Codebase sweep (5 parallel scanners + synthesis) for heuristics that **fake +judgment** an LLM would do better — in the spirit of the genotype→channel +refactor (drop the lookup table, let the model infer, keep a typed provenance +record + confirm-when-unsure). The flip side — logic that **must stay +deterministic** (safety, math, calibration, transport) — is listed at the end so +we don't mistakenly LLM-ify it. + +The unifying move for every candidate: **LLM with a typed structured-output +schema + provenance + a confirm/UNCERTAIN escape**, never free-text-then-parse. + +## Model candidates (ranked) + +### High value + +1. **Hatching / time-to-stage prediction** — `organisms/celegans/developmental_tracker.py` + *(the closest twin of genotype→channel; medium effort)* + Three hardcoded 20 °C lookup tables (`STAGE_TIMING_20C`, `TIME_TO_HATCHING`, + `TIMING_VARIABILITY`) plus magic `{HIGH:1.0, MEDIUM:1.5, LOW:2.0}` uncertainty + fudge factors. Structurally **can't use the rig's actual temperature** (we run + a TEC), the strain, or the embryo's observed progression rate. Let the model + produce a calibrated, explained interval; **keep the literature table as a + deterministic sanity bracket** and flag when the estimate falls outside it. + → `{ predicted_minutes_to_hatching, low, high, basis, assumptions{temperature_c,strain,used_observed_rate}, confidence, reasoning }` + +2. **Citation → PubMed query** — `harness/plan_mode/tools/research.py` (`_search_pmid`) + A regex that only handles "Surname et al YEAR …" + six hand-rolled query- + relaxation strategies + a stopword/word-position ladder that drops load-bearing + nouns. The model parses the sloppy citation and proposes relaxed queries; **code + keeps the deterministic esearch call and never fabricates a PMID.** + → `{ author_last, year, journal, topic_keywords[], organism, pubmed_query, alt_queries[], confidence }` + +3. **Lab-history retrieval** — `harness/plan_mode/tools/lab_context.py`, `harness/memory/interface.py` + Semantic recall faked by substring-OR over query tokens (matches "we"/"before", + misses every paraphrase). Feed the model the candidate records and have it + **rank/select from provided ids only** (no fabrication). Read-only, no + acquisition risk. + → `{ matches:[{kind,id,summary,relevance,why_relevant}], answer }` + +4. **Stage-label parse via 22-entry synonym dict** — `developmental_tracker.py` (`_parse_stage_name`) + *(small effort, pure robustness win)* The Vision call already classifies; the + brittleness is a plain-text `STAGE:/CONFIDENCE:` block scraped line-by-line, with + off-vocabulary phrasings silently collapsing to `UNKNOWN` (which kills the + downstream hatching prediction). Constrained-enum structured output deletes the + parser + synonym table. + → `{ stage: enum(...), confidence: enum(high|medium|low), is_transitional, reasoning }` + +### Medium value (mostly small — fix the output contract, not the judgment) + +5. **Calibration Vision calls** — `hardware/dispim/claude_client.py` + Four Vision calls return positional free text recovered by `'yes' in first_line` + / `re.search(r'\d+')` / first-valid-letter, with silent defaults (so "no, this is + not yes…" reads as *yes*). Typed output deletes the parse + silent-default layer. + +6. **ML architecture ranking** — `ml/architectures.py` (`get_suitable_architectures`) + Hard feasibility gates (VRAM / dataset) are correct **and stay**; the `+2/+1/+1` + point-score ranking that follows discards the per-arch prose. Let the model rank + the *pre-filtered feasible set* (ids constrained to that set). + +7. **Training label normalization** — `ml/data_loader.py` (`build_labels_from_store`) + Class space built by exact-string identity over free-text human annotations — + "1.5-fold" and "1.5 fold" become different classes. Model normalizes to the + canonical staging vocabulary, flags novel/ambiguous ones. + +### Lower value + +8. **"Plan has a control?"** — `plan_mode/tools/validation.py` — substring scan of a + 6-word keyword set; a scientific judgment over the whole plan. Non-blocking + warning → safe for the model. +9. **CGC HTML scraping** — `research.py` (`_cgc_search`) — positional multi-group + regex over fetched HTML; structured extraction the model does better (HTTP GET + stays code; **mark strain names low-confidence to avoid sending someone to order + a hallucinated strain**). + +### Cross-cutting batch (small each): typed output for the detector/verifier cluster +`harness/detection/verifier.py`, `app/detectors/hatching.py`, +`app/detectors/dopaminergic_signal.py`, `hardware/dispim/sam_detection.py` — all +already make the right model call but reconstruct the verdict via +`startswith`/regex-JSON-scraping with silent defaults. A batch move to native +structured output **strictly reduces parse-induced false negatives** without +touching the deterministic vote-tally/consensus/enum-dispatch downstream. + +**Reference implementations already in the repo (imitate, don't change):** +`dopaminergic_signal`'s perceiver→classifier rubric (typed enums, UNCERTAIN +escape, conservative-on-tie) and onboarding's `_extract_with_llm` (typed +extraction, degrade-to-verbatim fallback). + +## Keep deterministic (do NOT LLM-ify) +Safety, math, calibration, and transport — where a hallucinated value is unsafe +or breaks reproducibility: +- Laser-power safety limits + wavelength→MM-property map (`hardware/dispim/devices/optical.py`) +- SPIM trigger-timing arithmetic, piezo–galvo calibration, MM framing (`dispim/config.py`) +- Calibration prior EMA + R²≥0.75 slope-lock gate (`dispim/calibration.py`) +- SwitchBot GATT byte commands / status decoding (`hardware/switchbot.py`) +- Temperature setpoint bound [0,99.9] °C + stabilization I/O (`hardware/temperature.py`) +- Autofocus signal-processing, curve fitting, adaptive-sweep stop rules (`analysis/core.py`, `analysis/focus.py`) +- Classical-CV ROI detection + pixel→stage coordinate transforms (`detection.py`, `sam_detection.py` geometry) +- Timelapse rule dispatch + `confirm_timepoints` debounce + monotonic power ramp (`app/orchestration/timelapse.py`) +- Volume→b64 dark/flat calibration + fixed brightness scaling (`dopaminergic_signal._volume_to_b64` — deliberately non-adaptive) +- Wake-router debounce/throttle/stage-transition gate (`app/wake_router.py`) +- Plan hardware limits, detector-preset membership, dependency-cycle DFS, stage-order normalization (`plan_mode/tools/validation.py`) +- Ensemble vote tally + 0.70 quorum / unanimity consensus (`detection/verifier.py`) +- ML metric/aggregation math: confusion matrix, F1, federated averaging (`ml/evaluation.py`, `federated.py`) +- Core imaging geometry (max-projection, crop bounds, Euler rotations) + UI event reduction/routing/security (`core/imaging.py`, `ui/web/*`) +- Device-state SSE watchdog/staleness timers (`app/device_state_monitor.py`) +- Reference-type dispatch (PMID/DOI/URL by canonical syntax), `os.path.isfile` checks (`research.py`) + +## Note +`gap_assessment.conversation_weight` (the 0.25/0.1/0.05 readiness scalar) is now +largely **vestigial** — it only returns 'heavy' (lab onboarding) or 'none' — so +it's not worth an API call. Left off the candidate list. diff --git a/gently/app/detectors/hatching.py b/gently/app/detectors/hatching.py index 62dd31cb..ca63acd3 100644 --- a/gently/app/detectors/hatching.py +++ b/gently/app/detectors/hatching.py @@ -5,12 +5,16 @@ pipeline trains on. The dopaminergic-signal detector already returns ``has_hatched`` as part of its richer schema; this is a lighter-weight yes/no for use cases where structure / intensity assessment isn't needed. + +The verdict comes back as a forced tool call (``tool_choice`` pins the model +to ``record_hatching``), so the structured fields arrive already parsed as +``block.input`` — no JSON-from-prose scraping, no silent-default parse layer. """ import asyncio import logging import time -from typing import Any +from typing import Any, Dict, Optional import numpy as np @@ -20,8 +24,7 @@ logger = logging.getLogger(__name__) -_HATCHING_PROMPT = """You are observing a C. elegans embryo on a microscope. Decide whether -the embryo has HATCHED. +_HATCHING_PROMPT = """You are observing a C. elegans embryo on a microscope. Decide whether the embryo has HATCHED, then record your decision with the record_hatching tool. A HATCHED embryo: - Has visibly broken out of the eggshell @@ -32,39 +35,53 @@ - Is still contained within an intact eggshell - May be at any pre-hatching stage (bean, comma, 1.5-fold, 2-fold, pretzel) -Respond with ONLY a JSON object exactly matching this schema: +Default to has_hatched=false unless you are confident. Don't over-call hatching. +""" -{ - "has_hatched": true|false, - "confidence": "LOW|MEDIUM|HIGH", - "reasoning": "..." -} -Default to false unless you are confident. Don't over-call hatching. -""" +# Forced tool schema — the model is pinned to this via tool_choice, so the +# fields come back as a validated dict on the tool_use block. The conservative +# "default to false" guidance lives in the prompt. We deliberately do NOT ask +# the model to self-rate confidence — that's a heuristics-era artifact; the +# has_hatched judgment is the signal. +_HATCHING_TOOL = { + "name": "record_hatching", + "description": "Record whether the C. elegans embryo has hatched, with brief reasoning.", + "input_schema": { + "type": "object", + "properties": { + "has_hatched": { + "type": "boolean", + "description": "True only if the embryo has visibly broken out of the eggshell.", + }, + "reasoning": { + "type": "string", + "description": "One short sentence citing the visual evidence for the call.", + }, + }, + "required": ["has_hatched", "reasoning"], + }, +} class HatchingDetector(Detector): - """Claude-vision hatching yes/no, with confidence.""" + """Claude-vision hatching yes/no.""" name = "hatching" - def __init__(self, claude_client=None, model: str | None = None): + def __init__(self, claude_client=None, model: Optional[str] = None): self._claude = claude_client self._model = model async def run( self, volume: np.ndarray, - context: dict[str, Any], + context: Dict[str, Any], ) -> DetectorResult: + from gently.settings import settings import json - import re - import anthropic - from gently.settings import settings - embryo_id = context.get("embryo_id", "?") timepoint = int(context.get("timepoint", 0)) start = time.time() @@ -84,7 +101,7 @@ async def run( detector_name=self.name, embryo_id=embryo_id, timepoint=timepoint, - findings={"has_hatched": False, "confidence": "LOW"}, + findings={"has_hatched": False}, reasoning="Empty / unreadable volume", elapsed_ms=(time.time() - start) * 1000, ) @@ -93,41 +110,40 @@ async def run( response = await asyncio.to_thread( claude.messages.create, model=self._model or settings.models.fast, - max_tokens=200, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": _HATCHING_PROMPT}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": b64_image, - }, - }, - ], - } - ], + max_tokens=256, + tools=[_HATCHING_TOOL], + tool_choice={"type": "tool", "name": _HATCHING_TOOL["name"]}, + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": _HATCHING_PROMPT}, + {"type": "image", "source": { + "type": "base64", + "media_type": "image/png", + "data": b64_image, + }}, + ], + }], + ) + + # Forced tool_choice guarantees a tool_use block; read its parsed + # input directly. No regex, no JSON-from-prose fallback. + tool_input = next( + (b.input for b in response.content + if getattr(b, "type", None) == "tool_use"), + None, ) - raw = response.content[0].text if response.content else "" - findings = {"has_hatched": False, "confidence": "LOW"} + findings = {"has_hatched": False} reasoning = None err = None - try: - m = re.search(r"\{.*?\}", raw, re.DOTALL) - blob = m.group(0) if m else raw.strip() - parsed = json.loads(blob) - findings["has_hatched"] = bool(parsed.get("has_hatched", False)) - confidence = str(parsed.get("confidence", "LOW")).upper() - if confidence not in {"LOW", "MEDIUM", "HIGH"}: - confidence = "LOW" - findings["confidence"] = confidence - reasoning = parsed.get("reasoning") - except (json.JSONDecodeError, AttributeError) as e: - err = f"parse error: {e}" + if isinstance(tool_input, dict): + findings["has_hatched"] = bool(tool_input.get("has_hatched", False)) + reasoning = tool_input.get("reasoning") + else: + # Shouldn't happen with forced tool_choice — keep the + # conservative default and record why. + err = "no tool_use block in response" return DetectorResult( detector_name=self.name, @@ -135,16 +151,12 @@ async def run( timepoint=timepoint, findings=findings, reasoning=reasoning, - raw_response=raw, + raw_response=json.dumps(tool_input) if isinstance(tool_input, dict) else None, elapsed_ms=(time.time() - start) * 1000, error=err, ) - except ( - anthropic.APIConnectionError, - anthropic.RateLimitError, - anthropic.APIStatusError, - ) as e: + except (anthropic.APIConnectionError, anthropic.RateLimitError, anthropic.APIStatusError) as e: logger.error("[%s] Claude API error for %s: %s", self.name, embryo_id, e) return DetectorResult( detector_name=self.name, diff --git a/gently/harness/detection/verifier.py b/gently/harness/detection/verifier.py index 0370abb7..849fbdbf 100644 --- a/gently/harness/detection/verifier.py +++ b/gently/harness/detection/verifier.py @@ -14,35 +14,102 @@ import logging from dataclasses import dataclass, field from datetime import datetime -from typing import Any +from typing import Any, Callable, Dict, List, Optional import anthropic -from gently.core import EventType, get_event_bus from gently.settings import settings - +from .detector import Detector, DetectionResult, ConfidenceLevel from ..state import EmbryoState -from .detector import ConfidenceLevel, DetectionResult, Detector +from gently.core import EventType, get_event_bus logger = logging.getLogger(__name__) +# Each verification strategy is pinned to its tool via tool_choice, so the +# verdict arrives as a validated dict on the tool_use block — no +# startswith()-scraping of a "FIELD: VALUE" plain-text format, no silent +# defaults from a missed line. Downstream vote-tally / consensus logic is +# untouched: these helpers still produce the same strategy dataclasses. +# +# We deliberately don't ask the model to self-rate confidence (a heuristics-era +# artifact) — the boolean verdict is the signal, and the only confidence-like +# measure we keep is the ensemble's agreement ratio, which is *derived* from +# many independent votes rather than introspected by one call. +_ADVERSARIAL_TOOL = { + "name": "record_adversarial_review", + "description": "Record the critical review verdict: whether counter-evidence against the detection was found.", + "input_schema": { + "type": "object", + "properties": { + "found_counter_evidence": {"type": "boolean", "description": "True only if there is real evidence the detection is wrong."}, + "concerns": {"type": "array", "items": {"type": "string"}, "description": "Specific doubts or alternative explanations; empty list if none."}, + }, + "required": ["found_counter_evidence", "concerns"], + }, +} + +_INDEPENDENT_TOOL = { + "name": "record_independent_assessment", + "description": "Record an unbiased fresh assessment of whether the event occurred in this image.", + "input_schema": { + "type": "object", + "properties": { + "detected": {"type": "boolean", "description": "True if the event is observed in this image."}, + "key_evidence": {"type": "string", "description": "What specifically supports the conclusion."}, + }, + "required": ["detected", "key_evidence"], + }, +} + +_TEMPORAL_TOOL = { + "name": "record_temporal_comparison", + "description": "Record whether a real change consistent with the event occurred between the previous and current frames.", + "input_schema": { + "type": "object", + "properties": { + "change_detected": {"type": "boolean", "description": "True if a clear change consistent with the event is visible across frames."}, + "description": {"type": "string", "description": "The specific change observed between previous and current frames."}, + }, + "required": ["change_detected", "description"], + }, +} + +_HARDWARE_CONTEXT_TOOL = { + "name": "record_hardware_context", + "description": "Record whether hardware errors could have caused a false-positive detection.", + "input_schema": { + "type": "object", + "properties": { + "suspicious": {"type": "boolean", "description": "True if hardware errors could have affected image quality or positioning for this embryo."}, + "concerns": {"type": "array", "items": {"type": "string"}, "description": "Specific hardware concerns; empty list if none."}, + "reasoning": {"type": "string", "description": "Brief explanation of the analysis."}, + }, + "required": ["suspicious", "concerns", "reasoning"], + }, +} + + +def _tool_input(response) -> Optional[Dict[str, Any]]: + """Return the parsed input of the first tool_use block, or None.""" + for block in getattr(response, "content", None) or []: + if getattr(block, "type", None) == "tool_use": + return block.input + return None + + @dataclass class AdversarialResult: """Result of adversarial verification strategy""" - found_counter_evidence: bool - concerns: list[str] - confidence_in_original: ConfidenceLevel | None + concerns: List[str] raw_response: str @dataclass class IndependentResult: """Result of independent verification strategy""" - detected: bool - confidence: ConfidenceLevel | None key_evidence: str raw_response: str @@ -50,31 +117,27 @@ class IndependentResult: @dataclass class TemporalResult: """Result of temporal comparison strategy""" - change_detected: bool description: str - confidence: ConfidenceLevel | None raw_response: str @dataclass class EnsembleResult: """Result of ensemble voting strategy""" - votes_yes: int votes_no: int total_votes: int agreement_ratio: float # votes_yes / total_votes if detected, votes_no / total_votes if not consensus_detected: bool # True if >70% agree on YES - raw_responses: list[str] = field(default_factory=list) + raw_responses: List[str] = field(default_factory=list) @dataclass class HardwareContextResult: """Result of hardware context analysis strategy""" - suspicious: bool # True if hardware errors could have caused false positive - concerns: list[str] # Specific concerns identified + concerns: List[str] # Specific concerns identified reasoning: str raw_response: str @@ -82,16 +145,15 @@ class HardwareContextResult: @dataclass class VerificationResult: """Combined result of all verification strategies""" - original_detected: bool - original_confidence: ConfidenceLevel | None + original_confidence: Optional[ConfidenceLevel] # Strategy results adversarial: AdversarialResult independent: IndependentResult temporal: TemporalResult - ensemble: EnsembleResult | None = None # Only for hatching detection - hardware_context: HardwareContextResult | None = None # Only when errors present + ensemble: Optional[EnsembleResult] = None # Only for hatching detection + hardware_context: Optional[HardwareContextResult] = None # Only when errors present # Consensus consensus: bool = False @@ -101,50 +163,41 @@ class VerificationResult: timestamp: datetime = field(default_factory=datetime.now) verification_duration_seconds: float = 0.0 - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary""" result = { - "original_detected": self.original_detected, - "original_confidence": self.original_confidence.value - if self.original_confidence - else None, - "adversarial": { - "found_counter_evidence": self.adversarial.found_counter_evidence, - "concerns": self.adversarial.concerns, - "confidence_in_original": self.adversarial.confidence_in_original.value - if self.adversarial.confidence_in_original - else None, + 'original_detected': self.original_detected, + 'original_confidence': self.original_confidence.value if self.original_confidence else None, + 'adversarial': { + 'found_counter_evidence': self.adversarial.found_counter_evidence, + 'concerns': self.adversarial.concerns, }, - "independent": { - "detected": self.independent.detected, - "confidence": self.independent.confidence.value - if self.independent.confidence - else None, - "key_evidence": self.independent.key_evidence, + 'independent': { + 'detected': self.independent.detected, + 'key_evidence': self.independent.key_evidence, }, - "temporal": { - "change_detected": self.temporal.change_detected, - "description": self.temporal.description, - "confidence": self.temporal.confidence.value if self.temporal.confidence else None, + 'temporal': { + 'change_detected': self.temporal.change_detected, + 'description': self.temporal.description, }, - "consensus": self.consensus, - "consensus_reasoning": self.consensus_reasoning, - "timestamp": self.timestamp.isoformat(), - "verification_duration_seconds": self.verification_duration_seconds, + 'consensus': self.consensus, + 'consensus_reasoning': self.consensus_reasoning, + 'timestamp': self.timestamp.isoformat(), + 'verification_duration_seconds': self.verification_duration_seconds, } if self.ensemble: - result["ensemble"] = { - "votes_yes": self.ensemble.votes_yes, - "votes_no": self.ensemble.votes_no, - "total_votes": self.ensemble.total_votes, - "agreement_ratio": self.ensemble.agreement_ratio, - "consensus_detected": self.ensemble.consensus_detected, + result['ensemble'] = { + 'votes_yes': self.ensemble.votes_yes, + 'votes_no': self.ensemble.votes_no, + 'total_votes': self.ensemble.total_votes, + 'agreement_ratio': self.ensemble.agreement_ratio, + 'consensus_detected': self.ensemble.consensus_detected, } if self.hardware_context: - result["hardware_context"] = { - "suspicious": self.hardware_context.suspicious, - "concerns": self.hardware_context.concerns, - "reasoning": self.hardware_context.reasoning, + result['hardware_context'] = { + 'suspicious': self.hardware_context.suspicious, + 'concerns': self.hardware_context.concerns, + 'reasoning': self.hardware_context.reasoning, } return result @@ -189,7 +242,7 @@ def __init__( self.ensemble_threshold = ensemble_threshold self._event_bus = event_bus or get_event_bus() - def _emit_event(self, event_type: EventType, data: dict): + def _emit_event(self, event_type: EventType, data: Dict): """Emit event to viz server""" if self._event_bus: self._event_bus.publish(event_type, data) @@ -223,13 +276,19 @@ async def verify( start_time = datetime.now() # Run all strategies in parallel for speed - adversarial_task = self._run_adversarial(detector, embryo_state, original_result, timepoint) - independent_task = self._run_independent(detector, embryo_state, timepoint) - temporal_task = self._run_temporal_check(detector, embryo_state, timepoint) + adversarial_task = self._run_adversarial( + detector, embryo_state, original_result, timepoint + ) + independent_task = self._run_independent( + detector, embryo_state, timepoint + ) + temporal_task = self._run_temporal_check( + detector, embryo_state, timepoint + ) # For hatching detection, also run ensemble voting ensemble_result = None - if detector.name == "hatching": + if detector.name == 'hatching': ensemble_task = self._run_ensemble_hatching(embryo_state) adversarial, independent, temporal, ensemble_result = await asyncio.gather( adversarial_task, independent_task, temporal_task, ensemble_task @@ -261,11 +320,7 @@ async def verify( logger.info( f"Verification complete for {detector.name}: " f"consensus={consensus}, duration={duration:.2f}s" - + ( - f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" - if ensemble_result - else "" - ) + + (f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" if ensemble_result else "") ) return result @@ -304,15 +359,21 @@ async def verify_with_context( start_time = datetime.now() # Run all strategies in parallel for speed - adversarial_task = self._run_adversarial(detector, embryo_state, original_result, timepoint) - independent_task = self._run_independent(detector, embryo_state, timepoint) - temporal_task = self._run_temporal_check(detector, embryo_state, timepoint) + adversarial_task = self._run_adversarial( + detector, embryo_state, original_result, timepoint + ) + independent_task = self._run_independent( + detector, embryo_state, timepoint + ) + temporal_task = self._run_temporal_check( + detector, embryo_state, timepoint + ) # For hatching detection, also run ensemble voting ensemble_result = None hardware_result = None - if detector.name == "hatching": + if detector.name == 'hatching': ensemble_task = self._run_ensemble_hatching(embryo_state) # Run hardware context analysis if there are errors @@ -320,26 +381,11 @@ async def verify_with_context( hardware_task = self._run_hardware_context_analysis( global_error_context, embryo_state.id ) - ( - adversarial, - independent, - temporal, - ensemble_result, - hardware_result, - ) = await asyncio.gather( - adversarial_task, - independent_task, - temporal_task, - ensemble_task, - hardware_task, + adversarial, independent, temporal, ensemble_result, hardware_result = await asyncio.gather( + adversarial_task, independent_task, temporal_task, ensemble_task, hardware_task ) else: - ( - adversarial, - independent, - temporal, - ensemble_result, - ) = await asyncio.gather( + adversarial, independent, temporal, ensemble_result = await asyncio.gather( adversarial_task, independent_task, temporal_task, ensemble_task ) else: @@ -354,151 +400,88 @@ async def verify_with_context( # Adversarial result strategies_complete += 1 - self._emit_event( - EventType.VERIFICATION_STRATEGY, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "strategy": "adversarial", - "passed": not adversarial.found_counter_evidence, - "summary": ( - "Counter-evidence: " - + ( - "YES - " + ", ".join(adversarial.concerns) - if adversarial.found_counter_evidence - else "None found" - ) - ), - "confidence": adversarial.confidence_in_original.value - if adversarial.confidence_in_original - else None, - }, - ) - self._emit_event( - EventType.VERIFICATION_PROGRESS, - { - "embryo_id": embryo_id, - "strategies_complete": strategies_complete, - "total_strategies": total_strategies, - }, - ) + self._emit_event(EventType.VERIFICATION_STRATEGY, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'strategy': 'adversarial', + 'passed': not adversarial.found_counter_evidence, + 'summary': f"Counter-evidence: {'YES - ' + ', '.join(adversarial.concerns) if adversarial.found_counter_evidence else 'None found'}", + }) + self._emit_event(EventType.VERIFICATION_PROGRESS, { + 'embryo_id': embryo_id, + 'strategies_complete': strategies_complete, + 'total_strategies': total_strategies, + }) # Independent result strategies_complete += 1 - self._emit_event( - EventType.VERIFICATION_STRATEGY, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "strategy": "independent", - "passed": independent.detected, - "summary": ( - f"Independent detection: {'YES' if independent.detected else 'NO'}" - f" - {independent.key_evidence}" - ), - "confidence": independent.confidence.value if independent.confidence else None, - }, - ) - self._emit_event( - EventType.VERIFICATION_PROGRESS, - { - "embryo_id": embryo_id, - "strategies_complete": strategies_complete, - "total_strategies": total_strategies, - }, - ) + self._emit_event(EventType.VERIFICATION_STRATEGY, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'strategy': 'independent', + 'passed': independent.detected, + 'summary': f"Independent detection: {'YES' if independent.detected else 'NO'} - {independent.key_evidence}", + }) + self._emit_event(EventType.VERIFICATION_PROGRESS, { + 'embryo_id': embryo_id, + 'strategies_complete': strategies_complete, + 'total_strategies': total_strategies, + }) # Temporal result strategies_complete += 1 - self._emit_event( - EventType.VERIFICATION_STRATEGY, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "strategy": "temporal", - "passed": temporal.change_detected, - "summary": ( - f"Change detected: {'YES' if temporal.change_detected else 'NO'}" - f" - {temporal.description}" - ), - "confidence": temporal.confidence.value if temporal.confidence else None, - }, - ) - self._emit_event( - EventType.VERIFICATION_PROGRESS, - { - "embryo_id": embryo_id, - "strategies_complete": strategies_complete, - "total_strategies": total_strategies, - }, - ) + self._emit_event(EventType.VERIFICATION_STRATEGY, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'strategy': 'temporal', + 'passed': temporal.change_detected, + 'summary': f"Change detected: {'YES' if temporal.change_detected else 'NO'} - {temporal.description}", + }) + self._emit_event(EventType.VERIFICATION_PROGRESS, { + 'embryo_id': embryo_id, + 'strategies_complete': strategies_complete, + 'total_strategies': total_strategies, + }) # Ensemble result (if applicable) if ensemble_result: strategies_complete += 1 - self._emit_event( - EventType.VERIFICATION_STRATEGY, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "strategy": "ensemble", - "passed": ensemble_result.consensus_detected, - "summary": ( - f"Ensemble vote: {ensemble_result.votes_yes}/{ensemble_result.total_votes}" - f" YES ({ensemble_result.agreement_ratio * 100:.0f}%)" - ), - "votes_yes": ensemble_result.votes_yes, - "votes_no": ensemble_result.votes_no, - "total_votes": ensemble_result.total_votes, - }, - ) - self._emit_event( - EventType.VERIFICATION_PROGRESS, - { - "embryo_id": embryo_id, - "strategies_complete": strategies_complete, - "total_strategies": total_strategies, - }, - ) + self._emit_event(EventType.VERIFICATION_STRATEGY, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'strategy': 'ensemble', + 'passed': ensemble_result.consensus_detected, + 'summary': f"Ensemble vote: {ensemble_result.votes_yes}/{ensemble_result.total_votes} YES ({ensemble_result.agreement_ratio*100:.0f}%)", + 'votes_yes': ensemble_result.votes_yes, + 'votes_no': ensemble_result.votes_no, + 'total_votes': ensemble_result.total_votes, + }) + self._emit_event(EventType.VERIFICATION_PROGRESS, { + 'embryo_id': embryo_id, + 'strategies_complete': strategies_complete, + 'total_strategies': total_strategies, + }) # Hardware context result (if applicable) if hardware_result: strategies_complete += 1 - self._emit_event( - EventType.VERIFICATION_STRATEGY, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "strategy": "hardware_context", - "passed": not hardware_result.suspicious, - "summary": ( - "Hardware errors suspicious: " - + ( - "YES - " + ", ".join(hardware_result.concerns) - if hardware_result.suspicious - else "No" - ) - ), - "reasoning": hardware_result.reasoning, - }, - ) - self._emit_event( - EventType.VERIFICATION_PROGRESS, - { - "embryo_id": embryo_id, - "strategies_complete": strategies_complete, - "total_strategies": total_strategies, - }, - ) + self._emit_event(EventType.VERIFICATION_STRATEGY, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'strategy': 'hardware_context', + 'passed': not hardware_result.suspicious, + 'summary': f"Hardware errors suspicious: {'YES - ' + ', '.join(hardware_result.concerns) if hardware_result.suspicious else 'No'}", + 'reasoning': hardware_result.reasoning, + }) + self._emit_event(EventType.VERIFICATION_PROGRESS, { + 'embryo_id': embryo_id, + 'strategies_complete': strategies_complete, + 'total_strategies': total_strategies, + }) # Determine consensus (with hardware context) consensus, reasoning = self._evaluate_consensus_with_hardware( - original_result, - adversarial, - independent, - temporal, - ensemble_result, - hardware_result, + original_result, adversarial, independent, temporal, ensemble_result, hardware_result ) duration = (datetime.now() - start_time).total_seconds() @@ -519,37 +502,26 @@ async def verify_with_context( logger.info( f"Verification (with context) complete for {detector.name}: " f"consensus={consensus}, duration={duration:.2f}s" - + ( - f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" - if ensemble_result - else "" - ) + + (f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" if ensemble_result else "") + (f", hardware_suspicious={hardware_result.suspicious}" if hardware_result else "") ) # Emit VERIFICATION_COMPLETED event with full summary - self._emit_event( - EventType.VERIFICATION_COMPLETED, - { - "embryo_id": embryo_id, - "detector_name": detector.name, - "consensus": consensus, - "reasoning": reasoning, - "duration_seconds": duration, - "strategies": { - "adversarial": not adversarial.found_counter_evidence, - "independent": independent.detected, - "temporal": temporal.change_detected, - "ensemble": ensemble_result.consensus_detected if ensemble_result else None, - "hardware_context": (not hardware_result.suspicious) - if hardware_result - else None, - }, - "ensemble_votes": f"{ensemble_result.votes_yes}/{ensemble_result.total_votes}" - if ensemble_result - else None, + self._emit_event(EventType.VERIFICATION_COMPLETED, { + 'embryo_id': embryo_id, + 'detector_name': detector.name, + 'consensus': consensus, + 'reasoning': reasoning, + 'duration_seconds': duration, + 'strategies': { + 'adversarial': not adversarial.found_counter_evidence, + 'independent': independent.detected, + 'temporal': temporal.change_detected, + 'ensemble': ensemble_result.consensus_detected if ensemble_result else None, + 'hardware_context': (not hardware_result.suspicious) if hardware_result else None, }, - ) + 'ensemble_votes': f"{ensemble_result.votes_yes}/{ensemble_result.total_votes}" if ensemble_result else None, + }) return result @@ -577,8 +549,7 @@ async def _run_hardware_context_analysis( Analysis result """ try: - prompt = f"""You are analyzing hardware error context for a microscopy detection -verification. + prompt = f"""You are analyzing hardware error context for a microscopy detection verification. GLOBAL ERROR LOG: {global_error_context} @@ -591,28 +562,33 @@ async def _run_hardware_context_analysis( - Stage positioning errors could cause wrong embryo to be imaged - Acquisition timeouts could cause partial/blank images (blank images look like empty FOV = hatched) - Camera errors could produce corrupted data -- Errors on OTHER embryos in the same round could indicate systemic issues - (stage drift, hardware instability) +- Errors on OTHER embryos in the same round could indicate systemic issues (stage drift, hardware instability) - Multiple errors in quick succession suggests hardware problems -If ANY errors occurred that could have affected the image quality or positioning for -{embryo_id}, report as SUSPICIOUS. +If ANY errors occurred that could have affected the image quality or positioning for {embryo_id}, mark it suspicious. -Respond in EXACTLY this format: -SUSPICIOUS: [YES/NO] -CONCERNS: [list specific concerns, separated by semicolons] -REASONING: [brief explanation of your analysis] +Record your analysis with the record_hardware_context tool. """ response = await asyncio.to_thread( self.claude.messages.create, model=self.ensemble_model, # Use Haiku for speed max_tokens=300, - messages=[{"role": "user", "content": prompt}], + tools=[_HARDWARE_CONTEXT_TOOL], + tool_choice={"type": "tool", "name": _HARDWARE_CONTEXT_TOOL["name"]}, + messages=[{"role": "user", "content": prompt}] ) - response_text = response.content[0].text - return self._parse_hardware_context_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + concerns = data.get("concerns") or [] + return HardwareContextResult( + suspicious=bool(data.get("suspicious", True)), + concerns=[str(c) for c in concerns], + reasoning=str(data.get("reasoning", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Hardware context analysis failed: {e}") @@ -623,38 +599,14 @@ async def _run_hardware_context_analysis( raw_response="", ) - def _parse_hardware_context_response(self, response: str) -> HardwareContextResult: - """Parse hardware context analysis response""" - suspicious = False - concerns = [] - reasoning = "" - - for line in response.split("\n"): - line = line.strip() - if line.startswith("SUSPICIOUS:"): - value = line.split(":", 1)[1].strip().upper() - suspicious = value == "YES" - elif line.startswith("CONCERNS:"): - concerns_str = line.split(":", 1)[1].strip() - concerns = [c.strip() for c in concerns_str.split(";") if c.strip()] - elif line.startswith("REASONING:"): - reasoning = line.split(":", 1)[1].strip() - - return HardwareContextResult( - suspicious=suspicious, - concerns=concerns, - reasoning=reasoning, - raw_response=response, - ) - def _evaluate_consensus_with_hardware( self, original: DetectionResult, adversarial: AdversarialResult, independent: IndependentResult, temporal: TemporalResult, - ensemble: EnsembleResult | None = None, - hardware_context: HardwareContextResult | None = None, + ensemble: Optional[EnsembleResult] = None, + hardware_context: Optional[HardwareContextResult] = None, ) -> tuple[bool, str]: """ Evaluate consensus across all verification strategies including hardware context. @@ -670,9 +622,7 @@ def _evaluate_consensus_with_hardware( if not adversarial.found_counter_evidence: agreements += 1 else: - disagreements.append( - f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}" - ) + disagreements.append(f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}") # Check independent: should also detect if independent.detected: @@ -712,19 +662,12 @@ def _evaluate_consensus_with_hardware( consensus = agreements == total_strategies if consensus: - parts = [ - "no counter-evidence found", - "independent analysis confirms", - "temporal change observed", - ] + parts = ["no counter-evidence found", "independent analysis confirms", "temporal change observed"] if ensemble: parts.append(f"ensemble confirms ({ensemble.votes_yes}/{ensemble.total_votes} YES)") if hardware_context: parts.append("no hardware error concerns") - reasoning = ( - f"All verification strategies agree ({total_strategies}/{total_strategies}): " - + ", ".join(parts) - ) + reasoning = f"All verification strategies agree ({total_strategies}/{total_strategies}): " + ", ".join(parts) else: reasoning = ( f"Verification disagreement ({agreements}/{total_strategies} agree): " @@ -753,12 +696,11 @@ async def _run_adversarial( return AdversarialResult( found_counter_evidence=False, concerns=["No images available for verification"], - confidence_in_original=None, raw_response="", ) # Build detector-specific critical review guidance - if detector.name == "hatching": + if detector.name == 'hatching': specific_guidance = """ For HATCHING specifically, look for these common FALSE POSITIVE patterns: - Is the worm still COILED/PRETZEL-SHAPED inside the eggshell? @@ -771,12 +713,10 @@ async def _run_adversarial( else: specific_guidance = "" - prompt = f"""You are reviewing a detection result for a C. elegans embryo -(diSPIM max projection). + prompt = f"""You are reviewing a detection result for a C. elegans embryo (diSPIM max projection). The system detected: {detector.name} -Original confidence: {original_result.confidence.value if original_result.confidence else "unknown"} -Original reasoning: {original_result.reasoning or "not provided"} +Original reasoning: {original_result.reasoning or 'not provided'} NOW ACT AS A CRITICAL REVIEWER. Your job is to find reasons why this detection might be INCORRECT: - Could this be a false positive? @@ -784,10 +724,7 @@ async def _run_adversarial( - Is the evidence actually conclusive, or could it be interpreted differently? - Are there alternative explanations for what is observed? {specific_guidance} -Analyze the image(s) carefully and respond in EXACTLY this format: -COUNTER_EVIDENCE_FOUND: [YES/NO] -CONCERNS: [list specific doubts or alternative explanations, separated by semicolons] -CONFIDENCE_IN_ORIGINAL: [HIGH/MEDIUM/LOW] +Analyze the image(s) carefully and record your review with the record_adversarial_review tool. """ content = [{"type": "text", "text": prompt}] + images @@ -796,18 +733,26 @@ async def _run_adversarial( self.claude.messages.create, model=self.model, max_tokens=500, - messages=[{"role": "user", "content": content}], + tools=[_ADVERSARIAL_TOOL], + tool_choice={"type": "tool", "name": _ADVERSARIAL_TOOL["name"]}, + messages=[{"role": "user", "content": content}] ) - response_text = response.content[0].text - return self._parse_adversarial_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + concerns = data.get("concerns") or [] + return AdversarialResult( + found_counter_evidence=bool(data.get("found_counter_evidence", False)), + concerns=[str(c) for c in concerns], + raw_response=str(data), + ) except Exception as e: logger.error(f"Adversarial verification failed: {e}") return AdversarialResult( found_counter_evidence=False, concerns=[f"Verification error: {str(e)}"], - confidence_in_original=None, raw_response="", ) @@ -829,13 +774,12 @@ async def _run_independent( if not images: return IndependentResult( detected=False, - confidence=None, key_evidence="No images available", raw_response="", ) # Build detector-specific criteria - if detector.name == "hatching": + if detector.name == 'hatching': criteria = """ TRUE HATCHING criteria (must meet at least one): - Worm body is OUTSIDE the eggshell boundary (free-floating, elongated) @@ -849,8 +793,7 @@ async def _run_independent( criteria = detector.description # Use a neutral prompt that doesn't reveal the previous detection - prompt = f"""Analyze this C. elegans embryo image (diSPIM max projection) at -timepoint {timepoint}. + prompt = f"""Analyze this C. elegans embryo image (diSPIM max projection) at timepoint {timepoint}. Question: Has '{detector.name}' occurred in this embryo? @@ -859,10 +802,7 @@ async def _run_independent( Provide an independent assessment based SOLELY on what you observe in this image. Do not assume any prior state - analyze only what is visible now. -Respond in EXACTLY this format: -DETECTED: [YES/NO] -CONFIDENCE: [HIGH/MEDIUM/LOW] -KEY_EVIDENCE: [what specifically do you observe that supports your conclusion?] +Record your assessment with the record_independent_assessment tool. """ content = [{"type": "text", "text": prompt}] + images @@ -871,17 +811,24 @@ async def _run_independent( self.claude.messages.create, model=self.model, max_tokens=400, - messages=[{"role": "user", "content": content}], + tools=[_INDEPENDENT_TOOL], + tool_choice={"type": "tool", "name": _INDEPENDENT_TOOL["name"]}, + messages=[{"role": "user", "content": content}] ) - response_text = response.content[0].text - return self._parse_independent_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + return IndependentResult( + detected=bool(data.get("detected", False)), + key_evidence=str(data.get("key_evidence", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Independent verification failed: {e}") return IndependentResult( detected=False, - confidence=None, key_evidence=f"Verification error: {str(e)}", raw_response="", ) @@ -904,7 +851,6 @@ async def _run_temporal_check( return TemporalResult( change_detected=True, # Can't disprove without history description="Insufficient temporal history for comparison", - confidence=ConfidenceLevel.LOW, raw_response="", ) @@ -915,27 +861,24 @@ async def _run_temporal_check( prev_images = [] for img in embryo_state.recent_images[-3:-1]: if img.max_projection_b64: - prev_images.append( - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": img.max_projection_b64, - }, + prev_images.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": img.max_projection_b64, } - ) + }) if not prev_images: return TemporalResult( change_detected=True, description="No previous images available", - confidence=ConfidenceLevel.LOW, raw_response="", ) # Build detector-specific temporal criteria - if detector.name == "hatching": + if detector.name == 'hatching': temporal_criteria = """For HATCHING, look for: - A visible BREACH appearing in the eggshell boundary (not just expansion) - The worm physically EXITING the shell (part of body moves outside) @@ -947,11 +890,10 @@ async def _run_temporal_check( - Not just a static state that could have existed before - Clear evidence of progression or event occurrence""" - prompt = f"""Compare these sequential timepoints of a C. elegans embryo -(diSPIM max projection). + prompt = f"""Compare these sequential timepoints of a C. elegans embryo (diSPIM max projection). PREVIOUS TIMEPOINTS (shown first): -These are from t={timepoint - 2} to t={timepoint - 1} +These are from t={timepoint-2} to t={timepoint-1} CURRENT TIMEPOINT (shown last): This is t={timepoint} @@ -960,10 +902,7 @@ async def _run_temporal_check( {temporal_criteria} -Respond in EXACTLY this format: -CHANGE_DETECTED: [YES/NO] -DESCRIPTION: [what specific change do you see between the previous and current frames?] -CONFIDENCE: [HIGH/MEDIUM/LOW] +Record your comparison with the record_temporal_comparison tool. """ # Combine: previous images first, then prompt, then current @@ -973,38 +912,47 @@ async def _run_temporal_check( self.claude.messages.create, model=self.model, max_tokens=400, - messages=[{"role": "user", "content": content}], + tools=[_TEMPORAL_TOOL], + tool_choice={"type": "tool", "name": _TEMPORAL_TOOL["name"]}, + messages=[{"role": "user", "content": content}] ) - response_text = response.content[0].text - return self._parse_temporal_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + return TemporalResult( + change_detected=bool(data.get("change_detected", True)), + description=str(data.get("description", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Temporal verification failed: {e}") return TemporalResult( change_detected=True, # Don't block on error description=f"Verification error: {str(e)}", - confidence=None, raw_response="", ) - def _get_image_content(self, embryo_state: EmbryoState, num_images: int = 1) -> list[dict]: + def _get_image_content( + self, + embryo_state: EmbryoState, + num_images: int = 1 + ) -> List[Dict]: """Get image content blocks for Claude API""" images = [] recent = embryo_state.recent_images[-num_images:] if embryo_state.recent_images else [] for img in recent: if img.max_projection_b64: - images.append( - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": img.max_projection_b64, - }, + images.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": img.max_projection_b64, } - ) + }) return images @@ -1038,10 +986,8 @@ async def _run_ensemble_hatching(self, embryo_state: EmbryoState) -> EnsembleRes Answer ONE question: Has the embryo HATCHED? -HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, or field is -empty because worm left). -NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, even if -shell looks expanded). +HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, or field is empty because worm left). +NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, even if shell looks expanded). Respond with ONLY: YES or NO""" @@ -1053,7 +999,7 @@ async def single_vote() -> str: self.claude.messages.create, model=self.ensemble_model, max_tokens=10, # Very short response expected - messages=[{"role": "user", "content": content}], + messages=[{"role": "user", "content": content}] ) return response.content[0].text.strip().upper() except Exception as e: @@ -1061,10 +1007,7 @@ async def single_vote() -> str: return "ERROR" # Run all votes in parallel - logger.info( - f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls" - " for hatching verification" - ) + logger.info(f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls for hatching verification") tasks = [single_vote() for _ in range(self.ensemble_size)] responses = await asyncio.gather(*tasks) @@ -1113,95 +1056,13 @@ async def single_vote() -> str: raw_responses=[f"Error: {str(e)}"], ) - def _parse_adversarial_response(self, response: str) -> AdversarialResult: - """Parse adversarial strategy response""" - found_counter = False - concerns = [] - confidence = None - - for line in response.split("\n"): - line = line.strip() - if line.startswith("COUNTER_EVIDENCE_FOUND:"): - value = line.split(":", 1)[1].strip().upper() - found_counter = value == "YES" - elif line.startswith("CONCERNS:"): - concerns_str = line.split(":", 1)[1].strip() - concerns = [c.strip() for c in concerns_str.split(";") if c.strip()] - elif line.startswith("CONFIDENCE_IN_ORIGINAL:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - - return AdversarialResult( - found_counter_evidence=found_counter, - concerns=concerns, - confidence_in_original=confidence, - raw_response=response, - ) - - def _parse_independent_response(self, response: str) -> IndependentResult: - """Parse independent strategy response""" - detected = False - confidence = None - evidence = "" - - for line in response.split("\n"): - line = line.strip() - if line.startswith("DETECTED:"): - value = line.split(":", 1)[1].strip().upper() - detected = value == "YES" - elif line.startswith("CONFIDENCE:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - elif line.startswith("KEY_EVIDENCE:"): - evidence = line.split(":", 1)[1].strip() - - return IndependentResult( - detected=detected, - confidence=confidence, - key_evidence=evidence, - raw_response=response, - ) - - def _parse_temporal_response(self, response: str) -> TemporalResult: - """Parse temporal strategy response""" - change_detected = False - description = "" - confidence = None - - for line in response.split("\n"): - line = line.strip() - if line.startswith("CHANGE_DETECTED:"): - value = line.split(":", 1)[1].strip().upper() - change_detected = value == "YES" - elif line.startswith("DESCRIPTION:"): - description = line.split(":", 1)[1].strip() - elif line.startswith("CONFIDENCE:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - - return TemporalResult( - change_detected=change_detected, - description=description, - confidence=confidence, - raw_response=response, - ) - def _evaluate_consensus( self, original: DetectionResult, adversarial: AdversarialResult, independent: IndependentResult, temporal: TemporalResult, - ensemble: EnsembleResult | None = None, + ensemble: Optional[EnsembleResult] = None, ) -> tuple[bool, str]: """ Evaluate consensus across all verification strategies. @@ -1217,9 +1078,7 @@ def _evaluate_consensus( if not adversarial.found_counter_evidence: agreements += 1 else: - disagreements.append( - f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}" - ) + disagreements.append(f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}") # Check independent: should also detect if independent.detected: @@ -1253,14 +1112,13 @@ def _evaluate_consensus( f"All verification strategies agree ({total_strategies}/{total_strategies}): " f"no counter-evidence found, independent analysis confirms detection, " f"temporal change observed, ensemble voting confirms " - f"({ensemble.votes_yes}/{ensemble.total_votes}" - f" = {ensemble.agreement_ratio:.0%} YES)." + f"({ensemble.votes_yes}/{ensemble.total_votes} = {ensemble.agreement_ratio:.0%} YES)." ) else: reasoning = ( - "All verification strategies agree: " - "no counter-evidence found, independent analysis confirms detection, " - "temporal change observed." + f"All verification strategies agree: " + f"no counter-evidence found, independent analysis confirms detection, " + f"temporal change observed." ) else: reasoning = ( From 4910d061e43a1fc1058742477429ee1203d7f498 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 07:39:28 +0530 Subject: [PATCH 015/200] Fix recurring port-8080 false positive: SO_REUSEADDR on the viz preflight bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uvicorn binds with SO_REUSEADDR; the preflight check did not, making it stricter than the server it guards — a just-exited instance leaves client sockets in TIME_WAIT that fail a bare bind() even though uvicorn would bind fine, so quick restarts hit 'port in use' repeatedly. Set SO_REUSEADDR on the preflight so it fails only on a genuine live listener. Co-Authored-By: Claude Fable 5 --- gently/ui/web/server.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index faf2a2d8..daf91dba 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -784,6 +784,15 @@ async def on_start(self): import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Match uvicorn's own bind semantics. uvicorn sets SO_REUSEADDR before it + # binds, so a bare preflight bind WITHOUT it is *stricter* than the real + # server: when a previous instance has just exited, its browser/websocket + # connections linger in TIME_WAIT holding this local port, and a plain + # bind() fails with EADDRINUSE even though uvicorn would bind fine. That + # false positive was the recurring "port in use" on quick restarts. With + # SO_REUSEADDR the preflight now fails only on a genuine live listener + # (a real second instance) — exactly when uvicorn would also fail. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((self.host, self.port)) except OSError: From 5ee9e326c31ca24267269ff3c449e42033f4a541 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 07:40:14 +0530 Subject: [PATCH 016/200] chore: gitignore the stray D:/ storage dir On Linux the Windows default GENTLY_STORAGE_PATH (D:\Gently3) gets created literally as ./D:/ under the repo, full of logs/sessions. Ignore it so it stops cluttering status and can't be committed by accident. Co-Authored-By: Claude Fable 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index cce868ec..8bdd129d 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ electron/ /stage_definitions_for_review.txt gently/ui/tui/node_modules/ gently/ui/tui/dist/ + +# Stray local storage: on Linux the Windows default GENTLY_STORAGE_PATH +# (D:\Gently3) is created literally as ./D:/ under the repo. Not data we track. +/D:/ From a8fd3e53ef38eab241337ef0177d47c7930f6734 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 13:17:07 +0530 Subject: [PATCH 017/200] =?UTF-8?q?UX=20v2=20landing:=20fix=20welcome?= =?UTF-8?q?=E2=86=92plan=20jump,=20dark=20mode,=20and=20the=20entry=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish + flow fixes for the agent-first landing and in-page plan wizard. - Kill the welcome→plan "lift-up" lurch: top-anchor both screens to one shared offset so the orb no longer teleports ~140px on swap (now ~1px), with a single coordinated cross-fade keyframe. - Fix broken dark mode: define the tokens landing.css relied on but the theme never set (--bg, --text-secondary, --accent-soft, --accent-green-soft), scoped to body.ux-v2 with a light override; route the page background, drift glow, and accent-keyed shadows through real per-theme tokens. - A11y + polish: visible :focus-visible rings, animated tool-card reveal (grid-rows), feed scrolls internally with anchored header/footer, single- column mobile without a nested-scroll trap, consistent type scale + 4px spacing, ~40px touch targets, aria-expanded on disclosures, expanded prefers-reduced-motion coverage. - Entry flow: under ux_v2 the landing owns session entry, so suppress the legacy connect-time resolution picker server-side (it duplicated and contradicted the landing's Plan/Standalone choice). Guard the design kickoff so it fires once per session (no Back/forward pile-up). - "Plan an experiment" now offers continue-vs-fresh when an active campaign exists: continue it (default) or start a brand-new campaign from scratch. Co-Authored-By: Claude Fable 5 --- gently/ui/web/routes/agent_ws.py | 15 +- gently/ui/web/static/css/landing.css | 239 +++++++++++++---- gently/ui/web/static/js/landing.js | 386 +++++++++++++++++++++++---- gently/ui/web/templates/index.html | 9 +- 4 files changed, 548 insertions(+), 101 deletions(-) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index fdf2fc5f..887fdd25 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -14,6 +14,8 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from gently.settings import settings + logger = logging.getLogger(__name__) @@ -744,9 +746,18 @@ async def _run_resolution_bootstrap(): pass if not wizard_ran: - if bridge.should_enter_resolution(): + enter_resolution = bridge.should_enter_resolution() + # Under ux_v2 the agent-first landing owns the session-entry + # decision ("Plan an experiment" / "Take a quick look"), so the + # legacy connect-time resolution picker would just duplicate it — + # and contradict it, by offering "Standalone" after the user has + # already chosen to plan. Stay quiet on connect for new sessions; + # the landing drives plan-mode (/plan) or standalone instead. + if enter_resolution and not settings.ui.ux_v2: bootstrap_task = asyncio.create_task(_run_resolution_bootstrap()) - else: + elif not enter_resolution: + # Resume / already-resolved sessions still get their briefing + # (it sits behind the landing overlay until dismissed). briefing = bridge.get_session_briefing() if briefing: await send_fn({"type": "stream_start"}) diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css index c5152cf5..fff753e8 100644 --- a/gently/ui/web/static/css/landing.css +++ b/gently/ui/web/static/css/landing.css @@ -6,19 +6,46 @@ but reuses production's CSS variables (with the prototype hexes as fallback) so it tracks the app theme. */ +/* ux_v2 landing fills the gaps in the production token set (main.css defines + --bg-dark/-card/-hover, --border, --text, --text-muted, --accent, --accent-green + but NOT a page-bg alias, a secondary-text, or accent tints). Scope to + body.ux-v2 so v1 is untouched; both themes resolved here so landing.css can + reference these like any real token. dark is the default theme (main.css :root). */ +body.ux-v2 { + --bg: var(--bg-dark); /* page background, theme-aware */ + --text-secondary: var(--text-muted); + --accent-soft: rgba(96,165,250,.15); /* tint of dark --accent #60a5fa */ + --accent-green-soft: rgba(74,222,128,.15); /* tint of dark --accent-green */ + /* one disciplined type scale for the landing/plan surface */ + --v2-fs-body: 14px; + --v2-fs-sm: 13px; + --v2-fs-cap: 12px; + --v2-fs-eyebrow: 11px; +} +body.ux-v2[data-theme="light"] { + --accent-soft: rgba(59,130,246,.10); /* tint of light --accent #3b82f6 */ + --accent-green-soft: rgba(34,197,94,.12); /* tint of light --accent-green */ +} +/* accent-keyed glows can't put var() inside rgba channels, so the dark defaults + live on the elements (re-keyed off the dead #2f6df6 onto the real #60a5fa) and + light overrides ride here next to the tokens. */ +body.ux-v2[data-theme="light"] .v2-landing-orb { box-shadow: 0 6px 22px rgba(59,130,246,.35), inset 0 0 12px rgba(255,255,255,.6); } +body.ux-v2[data-theme="light"] .v2-escape-field input:focus { box-shadow: 0 0 0 4px rgba(59,130,246,.12); } +body.ux-v2[data-theme="light"] .v2-escape-send { box-shadow: 0 6px 16px rgba(59,130,246,.35); } + .v2-landing { position: fixed; inset: 0; z-index: 200; display: flex; - align-items: center; + align-items: flex-start; /* BOTH screens top-anchored — no discrete switch on swap */ justify-content: center; padding: 24px; overflow: hidden; background: - radial-gradient(1100px 700px at 78% -8%, #eaf1ff 0%, transparent 55%), - radial-gradient(900px 600px at 8% 108%, #eafaf0 0%, transparent 55%), - var(--bg, #f6f8fb); + radial-gradient(1100px 700px at 78% -8%, var(--accent-soft) 0%, transparent 55%), + radial-gradient(900px 600px at 8% 108%, var(--accent-green-soft) 0%, transparent 55%), + var(--bg); transition: opacity .5s cubic-bezier(.22,1,.36,1), transform .5s cubic-bezier(.22,1,.36,1), visibility .5s; } /* The calm screen "unfolds" into the workspace: fade + slight scale-up, then @@ -34,9 +61,10 @@ content: ""; position: absolute; inset: -20vmax; - background: radial-gradient(closest-side, rgba(47,109,246,.10), transparent 70%); + background: radial-gradient(closest-side, var(--accent-soft), transparent 70%); filter: blur(30px); animation: v2land-drift 26s cubic-bezier(.22,1,.36,1) infinite alternate; + will-change: transform; pointer-events: none; } @keyframes v2land-drift { @@ -52,6 +80,8 @@ align-items: center; max-width: 760px; width: 100%; + margin-top: 7vh; /* shared anchor for welcome AND plan — orb stays put on swap */ + margin-bottom: 5vh; } .v2-landing-rise { animation: v2land-rise .6s cubic-bezier(.22,1,.36,1) backwards; } .v2-landing-rise[data-i="1"] { animation-delay: .07s; } @@ -64,7 +94,7 @@ .v2-landing-orb { width: 52px; height: 52px; border-radius: 50%; background: radial-gradient(closest-side at 38% 34%, #ffffff, #bcd3ff 40%, var(--accent, #2f6df6) 100%); - box-shadow: 0 6px 22px rgba(47,109,246,.45), inset 0 0 12px rgba(255,255,255,.6); + box-shadow: 0 6px 22px rgba(96,165,250,.45), inset 0 0 12px rgba(255,255,255,.6); animation: v2land-breathe 4s ease-in-out infinite; } @keyframes v2land-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } } @@ -76,33 +106,44 @@ /* choice cards */ .v2-landing-choices { - display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 18px; - margin-top: 34px; width: min(720px, 92vw); + display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px; + margin-top: 32px; width: min(720px, 92vw); } @media (max-width: 620px) { .v2-landing-choices { grid-template-columns: 1fr; } } .v2-choice { text-align: left; cursor: pointer; position: relative; overflow: hidden; border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); - border-radius: 18px; padding: 22px 22px 20px; + border-radius: 18px; padding: 20px; box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); font: inherit; color: var(--text, #0f172a); transition: transform .26s cubic-bezier(.22,1,.36,1), box-shadow .26s cubic-bezier(.22,1,.36,1), border-color .26s; } .v2-choice:hover { - transform: translateY(-4px); border-color: #cfdcf5; - box-shadow: 0 2px 6px rgba(15,23,42,.06), 0 18px 50px rgba(47,109,246,.14); + transform: translateY(-4px); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 2px 6px rgba(15,23,42,.06), + 0 18px 50px color-mix(in srgb, var(--accent) 16%, transparent); } .v2-choice:active { transform: translateY(-1px) scale(.995); } + +/* Visible keyboard focus for every landing/plan control (mouse clicks get no ring) */ +#v2-landing :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 12px; /* hug the pill/card corners */ +} +#v2-landing .v2-choice:focus-visible { outline-offset: -2px; } /* inset: the card clips outside outlines */ +#v2-landing .v2-escape-field input:focus-visible { outline-offset: 0; } .v2-choice-ic { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); margin-bottom: 14px; } .v2-choice.alt .v2-choice-ic { background: var(--accent-green-soft, #e7f6ec); color: var(--accent-green, #16a34a); } .v2-choice h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -.01em; } -.v2-choice p { margin: 0; color: var(--text-secondary, #475569); font-size: 13.5px; line-height: 1.5; } +.v2-choice p { margin: 0; color: var(--text-secondary, #475569); font-size: var(--v2-fs-sm); line-height: 1.5; } .v2-choice-tag { position: absolute; top: 16px; right: 16px; - font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #94a3b8); + font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); border: 1px solid var(--border, #e4e9f0); border-radius: 999px; padding: 3px 9px; } .v2-choice-go { @@ -114,15 +155,15 @@ .v2-choice:hover .v2-choice-go { opacity: 1; transform: none; } /* escape hatch — chat is the last resort, an obvious pill */ -.v2-escape { margin-top: 26px; display: flex; flex-direction: column; align-items: center; } +.v2-escape { margin-top: 24px; display: flex; flex-direction: column; align-items: center; } .v2-escape-toggle { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font: inherit; font-size: 13px; background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); - padding: 8px 15px; border-radius: 999px; + padding: 11px 16px; border-radius: 999px; box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); transition: color .2s, border-color .2s, transform .2s cubic-bezier(.22,1,.36,1); } -.v2-escape-toggle:hover { color: var(--text, #0f172a); border-color: #cfd9e6; transform: translateY(-1px); } +.v2-escape-toggle:hover { color: var(--text, #0f172a); border-color: var(--border-strong); transform: translateY(-1px); } .v2-escape-toggle .v2-escape-caret { display: inline-block; transition: transform .3s cubic-bezier(.22,1,.36,1); opacity: .55; } .v2-escape.open .v2-escape-toggle .v2-escape-caret { transform: rotate(180deg); } .v2-escape-field { @@ -133,22 +174,22 @@ .v2-escape.open .v2-escape-field { max-height: 64px; opacity: 1; margin-top: 12px; } .v2-escape-field input { flex: 1; min-width: 0; border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); - border-radius: 12px; padding: 12px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a); + border-radius: 12px; padding: 12px 14px; font: inherit; font-size: var(--v2-fs-body); color: var(--text, #0f172a); outline: none; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: border-color .2s, box-shadow .2s; } -.v2-escape-field input:focus { border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(47,109,246,.12); } +.v2-escape-field input:focus { border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96,165,250,.18); } .v2-escape-send { appearance: none; border: 0; cursor: pointer; flex: none; width: 42px; height: 42px; border-radius: 12px; background: var(--accent, #2f6df6); color: #fff; display: grid; place-items: center; - box-shadow: 0 6px 16px rgba(47,109,246,.35); transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; + box-shadow: 0 6px 16px rgba(96,165,250,.40); transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; } .v2-escape-send:hover { transform: translateY(-1px); filter: brightness(1.05); } /* one-way skip into the workspace (e.g. a reload mid-session) */ .v2-landing-skip { - margin-top: 28px; background: none; border: 0; cursor: pointer; font: inherit; font-size: 12.5px; - color: var(--text-muted, #94a3b8); padding: 6px 10px; border-radius: 8px; + margin-top: 24px; background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-cap); + color: var(--text-muted, #94a3b8); padding: 10px 12px; border-radius: 8px; transition: color .2s; } .v2-landing-skip:hover { color: var(--text-secondary, #475569); } @@ -164,29 +205,46 @@ body.ux-v2 .home-hero { display: none; } .v2-landing[data-screen="welcome"] .v2-screen-welcome { display: flex; flex-direction: column; align-items: center; max-width: 760px; margin: 0 auto; + animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards; } .v2-landing[data-screen="plan"] .v2-screen-plan { display: flex; flex-direction: column; - animation: v2land-rise .45s cubic-bezier(.22,1,.36,1) backwards; + animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards; +} +/* One swap motion shared by both screens: a soft opacity + rise + settle. The + scale .992→1 echoes the dismissed-state scale(1.015) so the surface feels like + one continuous fabric folding, not two slides swapping. */ +@keyframes v2land-plan-in { + from { opacity: 0; transform: translateY(10px) scale(.992); } + to { opacity: 1; transform: none; } } /* plan header */ -.v2-plan-head { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } -.v2-plan-orb { width: 34px; height: 34px; } -.v2-plan-who { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.v2-plan-orb { width: 40px; height: 40px; transition: width .42s cubic-bezier(.22,1,.36,1), height .42s cubic-bezier(.22,1,.36,1); } +.v2-plan-who { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } .v2-plan-title { font-size: 18px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); } .v2-plan-back { margin-left: auto; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; - color: var(--text-muted, #94a3b8); padding: 6px 10px; border-radius: 8px; transition: color .2s, background .2s; + color: var(--text-muted, #94a3b8); padding: 9px 12px; border-radius: 8px; transition: color .2s, background .2s; } .v2-plan-back:hover { color: var(--text, #0f172a); background: rgba(15,23,42,.05); } /* plan body: ask stage + assembling plan */ .v2-plan-wrap { display: grid; grid-template-columns: 1.35fr .9fr; gap: 20px; align-items: start; } -@media (max-width: 720px) { .v2-plan-wrap { grid-template-columns: 1fr; } } +@media (max-width: 720px) { + .v2-plan-wrap { grid-template-columns: 1fr; } + /* single column: THE PLAN sits BELOW the feed — drop the internal scroll + and let the whole plan screen scroll as one document instead. The + descendant selector outranks the plain `.v2-plan-main { max-height }` + rule that appears later in the file (equal specificity → source order), + so the cap is genuinely lifted here, not silently re-applied. */ + .v2-landing[data-screen="plan"] .v2-plan-main { height: auto; max-height: none; overflow-y: visible; padding-right: 0; } + .v2-landing[data-screen="plan"] { overflow-y: auto; } +} .v2-plan-main { min-height: 220px; } .v2-plan-ask:empty { display: none; } -.v2-plan-thinking { display: flex; align-items: center; gap: 9px; color: var(--text-muted, #94a3b8); font-size: 13.5px; padding: 22px 4px; } +.v2-plan-thinking { display: flex; align-items: center; gap: 9px; color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); padding: 20px 4px; } .v2-plan-thinking.hidden { display: none; } .v2-typing { display: inline-flex; gap: 5px; align-items: center; } .v2-typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #2f6df6); opacity: .4; animation: v2-blink 1.1s infinite; } @@ -195,33 +253,124 @@ body.ux-v2 .home-hero { display: none; } @keyframes v2-blink { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } } .v2-plan-side { - background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); border-radius: 14px; padding: 16px 18px; - box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); + background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); border-radius: 14px; padding: 14px 16px; + box-shadow: 0 1px 2px rgba(15,23,42,.04); } -.v2-plan-side-h { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 12px; } -.v2-plan-side-empty { color: var(--text-muted, #94a3b8); font-size: 13px; font-style: italic; } +.v2-plan-side-h { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 10px; } +.v2-plan-side-empty { color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); font-style: italic; } .v2-plan-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 0; border-top: 1px dashed var(--border, #e4e9f0); } .v2-plan-row:first-child { border-top: 0; } -.v2-plan-row .k { font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } -.v2-plan-row .v { font-size: 14px; color: var(--text, #0f172a); font-weight: 550; } +.v2-plan-row .k { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-row .v { font-size: var(--v2-fs-body); color: var(--text, #0f172a); font-weight: 600; } -/* plan footer */ -.v2-plan-foot { display: flex; align-items: center; gap: 10px; margin-top: 22px; } +/* plan footer: "Open conversation" (quiet, left), a spacer, then "Continue in + workspace" demoted to a text link (right). The agent's recommended option in + the ask card is the real primary action now — the footer no longer competes. */ +.v2-plan-foot { display: flex; align-items: center; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border, #e4e9f0); } +.v2-plan-foot-spacer { flex: 1; } .v2-plan-chat { - margin-right: auto; background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); - border-radius: 999px; padding: 8px 14px; font: inherit; font-size: 13px; cursor: pointer; transition: border-color .2s, color .2s; + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm); cursor: pointer; transition: border-color .2s, color .2s; } -.v2-plan-chat:hover { border-color: #cfd9e6; color: var(--text, #0f172a); } -.v2-plan-continue { - background: var(--accent, #2f6df6); color: #fff; border: 0; border-radius: 10px; padding: 10px 16px; - font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; +.v2-plan-chat:hover { border-color: var(--border-strong); color: var(--text, #0f172a); } +.v2-plan-skip { + background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-sm); + color: var(--text-muted, #94a3b8); padding: 11px 10px; border-radius: 8px; transition: color .2s; } -.v2-plan-continue:hover { transform: translateY(-1px); filter: brightness(1.05); } +.v2-plan-skip:hover { color: var(--text-secondary, #475569); } + +/* ── Agent-activity feed: claude.ai-style collapsible tool cards ──────────── */ +/* Both screens share the .v2-landing-inner top anchor (no per-screen align flip — + that was the welcome→plan lurch). The feed is a fixed-height viewport (height + set above) so the streaming activity column scrolls on its own without ever + reflowing the anchored header/footer around it. Short feeds stay compact + (min-height above); long feeds cap at 66vh and scroll internally. The header + never moves because the inner is top-anchored — only the footer rides down as + the feed grows, up to the cap. */ +.v2-plan-main { max-height: 66vh; overflow-y: auto; padding-right: 4px; } + +.v2-plan-activity { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } +.v2-plan-activity:empty { display: none; margin: 0; } + +/* agent prose between tool calls */ +.v2-act-text { font-size: var(--v2-fs-sm); line-height: 1.55; color: var(--text-secondary, #475569); white-space: pre-wrap; } + +/* collapsed-by-default tool card; the header toggles .open to reveal the body */ +.v2-act-tool { border: 1px solid var(--border, #e4e9f0); border-radius: 11px; background: var(--bg-card, #fff); overflow: hidden; } +.v2-act-tool-head { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: 0; cursor: pointer; text-align: left; font: inherit; + padding: 11px 12px; color: var(--text, #0f172a); +} +.v2-act-tool-head:hover { background: var(--bg-hover, #f1f5f9); } +.v2-act-ic { width: 16px; flex: none; text-align: center; font-size: 12px; } +.v2-act-tool.done .v2-act-ic { color: var(--accent-green, #16a34a); } +.v2-act-tool.err .v2-act-ic { color: #ea580c; } +body.ux-v2[data-theme="dark"] .v2-act-tool.err .v2-act-ic { color: #fb923c; } +.v2-act-spin { + display: inline-block; width: 11px; height: 11px; border-radius: 50%; + border: 2px solid var(--border, #e4e9f0); border-top-color: var(--accent, #2f6df6); + animation: v2-act-spin .7s linear infinite; +} +@keyframes v2-act-spin { to { transform: rotate(360deg); } } +.v2-act-label { font-size: var(--v2-fs-sm); font-weight: 600; flex: none; } +.v2-act-summary { font-size: var(--v2-fs-cap); color: var(--text-muted, #94a3b8); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-act-chev { flex: none; color: var(--text-muted, #94a3b8); transition: transform .2s; font-size: 13px; } +.v2-act-tool.open .v2-act-chev { transform: rotate(90deg); } +/* animatable collapse: grid-template-rows 0fr→1fr eases in step with the chevron + (display:none isn't animatable). Needs exactly ONE min-height:0 child — landing.js + wraps the blocks in a single inner div for this. */ +.v2-act-tool-body { + display: grid; grid-template-rows: 0fr; opacity: 0; + padding: 0 12px 0 37px; + transition: grid-template-rows .26s cubic-bezier(.22,1,.36,1), + opacity .26s cubic-bezier(.22,1,.36,1), + padding-bottom .26s cubic-bezier(.22,1,.36,1); +} +.v2-act-tool-body > * { min-height: 0; overflow: hidden; } +.v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; padding-bottom: 11px; } +.v2-act-tool.open .v2-act-summary { white-space: normal; } +.v2-act-block-label { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-top: 8px; } +.v2-act-block { + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + background: var(--bg-hover); border: 1px solid var(--border, #e4e9f0); border-radius: 8px; + padding: 8px 10px; margin-top: 4px; white-space: pre-wrap; word-break: break-word; + color: var(--text-secondary, #475569); max-height: 220px; overflow: auto; +} + +/* error + fallback states */ +.v2-plan-error { + font-size: var(--v2-fs-sm); color: #b91c1c; + background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.35); + border-radius: 11px; padding: 11px 13px; +} +body.ux-v2[data-theme="dark"] .v2-plan-error { + color: #fca5a5; background: rgba(239,68,68,.14); border-color: rgba(239,68,68,.40); +} +.v2-plan-error.hidden { display: none; } +.v2-plan-fallback { font-size: 13px; color: var(--text-muted, #94a3b8); padding: 8px 2px; } +.v2-plan-fallback a { color: var(--accent, #2f6df6); cursor: pointer; } + +/* plan-panel: phases + tasks (real plan), and a free-text-answer row variant */ +.v2-plan-phase { margin-top: 12px; } +.v2-plan-phase:first-child { margin-top: 0; } +.v2-plan-phase-h { font-size: 13px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 5px; } +.v2-plan-task { display: flex; gap: 8px; font-size: var(--v2-fs-cap); color: var(--text-secondary, #475569); padding: 2px 0; } +.v2-plan-task::before { content: "·"; color: var(--text-muted, #94a3b8); } +.v2-plan-title-row { font-size: var(--v2-fs-sm); font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 8px; } +.v2-plan-row.v2-plan-row-freetext .v { font-style: italic; } @media (prefers-reduced-motion: reduce) { - .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, - .v2-landing[data-screen="plan"] .v2-screen-plan, .v2-typing i { + .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, .v2-plan-orb, + .v2-landing[data-screen="plan"] .v2-screen-plan, + .v2-landing[data-screen="welcome"] .v2-screen-welcome, + .v2-typing i, .v2-act-spin, .v2-act-chev, + .v2-act-tool-body, .v2-act-tool-head, + .v2-choice, .v2-escape-field, .v2-escape-toggle { animation: none !important; transition-duration: .12s !important; } + /* keep the collapsible usable without the height tween */ + .v2-act-tool-body { transition: none !important; } + .v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; } } diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index 35eaf1fa..2f524c1e 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -1,23 +1,26 @@ /** * V2Landing (ux_v2): the agent-first welcome AND the in-place plan wizard. * - * The key paradigm fix: the plan dialogue renders IN THE LANDING, not the chat - * REPL. Clicking "Plan an experiment" doesn't recede to the dashboard — it - * switches the landing to a plan screen, enters plan mode, and renders the - * agent's ask_user_choice questions as button cards right there (#v2-plan-ask), - * reusing AgentChat.buildAskCard so it's the same card/answer path as elsewhere. - * The plan assembles on the right from each pick. Chat is never the surface - * (it's one click away via "Open conversation" as the last resort). - * - * Plan an experiment → switch to plan screen; /plan + kickoff; asks render here - * Take a quick look → scope (Devices view) - * "just tell me…" → free-text into the agent loop (then chat as transcript) + * Clicking "Plan an experiment" switches the landing to a plan screen, enters + * plan mode, and renders the agent's work IN THE WIZARD — not the chat REPL: + * - the agent's reasoning + tool calls render as a tidy, claude.ai-style + * collapsible activity feed (#v2-plan-activity), fed by the AGENT_ACTIVITY + * event that agent-chat.js mirrors off the /ws/agent stream; + * - the agent's ask_user_choice questions render as button cards + * (#v2-plan-ask) via AgentChat.buildAskCard; + * - "THE PLAN" panel (#v2-plan-summary) mirrors the REAL plan (phases→tasks) + * fetched from /api/campaigns once a turn settles. + * Chat is the last resort (the escape pill / "Open conversation"). * * No-ops unless #v2-landing is present (flag off → v1 untouched, overlay absent). */ const V2Landing = (() => { let el = null; - let current = null; // { reqId, data, isWake } — the ask showing in the plan stage + let current = null; // the ask currently in #v2-plan-ask + let feedTextEl = null; // current accumulating prose paragraph in the feed + let runningTools = {}; // tool name -> stack of running card elements + let feedHadContent = false; // did this turn surface anything in the feed? + let capturedCampaignId = null; // best-effort id scraped from tool results const $ = (id) => document.getElementById(id); @@ -50,32 +53,255 @@ const V2Landing = (() => { setTimeout(finish, 650); } - // ── plan stage helpers ──────────────────────────────────────────── - function showThinking(on) { - const t = $('v2-plan-thinking'); - if (t) t.classList.toggle('hidden', !on); + // ── status / error helpers ──────────────────────────────────────── + function showThinking(on) { const t = $('v2-plan-thinking'); if (t) t.classList.toggle('hidden', !on); } + function errorVisible() { const e = $('v2-plan-error'); return !!e && !e.classList.contains('hidden'); } + function showPlanError(msg) { + const e = $('v2-plan-error'); if (!e) return; + e.textContent = msg; e.classList.remove('hidden'); + showThinking(false); } + function hidePlanError() { const e = $('v2-plan-error'); if (e) e.classList.add('hidden'); } + function clearAsk() { const m = $('v2-plan-ask'); if (m) m.innerHTML = ''; } function resetSummary() { const list = $('v2-plan-summary'); - if (list) list.innerHTML = '
Assembling from your choices…
'; + if (list) list.innerHTML = '
The plan will take shape here as Gently designs it.
'; + } + + // ── activity feed (claude.ai-style collapsible tool cards) ───────── + function feedEl() { return $('v2-plan-activity'); } + function clearActivity() { + const f = feedEl(); if (f) f.innerHTML = ''; + feedTextEl = null; runningTools = {}; feedHadContent = false; + capturedCampaignId = null; + hidePlanError(); + } + function scrollFeedIfNearBottom() { + const m = document.querySelector('.v2-screen-plan .v2-plan-main'); + if (m && (m.scrollHeight - m.scrollTop - m.clientHeight) < 140) m.scrollTop = m.scrollHeight; + } + function clearFallback() { feedEl()?.querySelectorAll('.v2-plan-fallback').forEach(n => n.remove()); } + + // Render the agent's prose like the chat does (reuses AgentChat.mdToHtml — + // escapes then renders bold/italic/code/line-breaks), so the feed isn't raw + // markdown. Falls back to escaped text if the helper isn't available. + function renderMd(s) { + if (typeof AgentChat !== 'undefined' && AgentChat.mdToHtml) return AgentChat.mdToHtml(s); + const esc = (typeof escapeHtml === 'function') ? escapeHtml(String(s)) : String(s); + return esc.replace(/\n/g, '
'); + } + + // Plan-writing tools → refresh THE PLAN panel during the turn (debounced), + // not only at turn_end (ask_user_choice pauses the turn before it ends). + const PLAN_TOOLS = new Set([ + 'create_campaign', 'create_plan_item', 'link_plan_items', 'update_plan_item', + 'delete_plan_item', 'propose_plan', 'get_plan_status', 'validate_plan', + ]); + let planRefreshTimer = null; + function schedulePlanRefresh() { + if (planRefreshTimer) clearTimeout(planRefreshTimer); + planRefreshTimer = setTimeout(() => { planRefreshTimer = null; refreshPlanPanel(); }, 600); + } + + function safeStringify(v) { + try { + const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 2); + return s.length > 4000 ? s.slice(0, 4000) + '\n…' : s; + } catch (e) { return String(v); } + } + function fillToolBody(body, act) { + body.innerHTML = ''; + // grid-template-rows reveal (landing.css) needs ONE collapsible child — + // append blocks into a single inner wrapper, not directly onto body. + const inner = document.createElement('div'); + body.appendChild(inner); + const inputStr = (act.input != null) ? safeStringify(act.input) : ''; + const full = act.full || act.summary || ''; + const block = (label, text) => { + const l = document.createElement('div'); l.className = 'v2-act-block-label'; l.textContent = label; + const b = document.createElement('pre'); b.className = 'v2-act-block'; b.textContent = text; + inner.append(l, b); + }; + if (inputStr) block('input', inputStr); + if (full) block('result', full); + if (!inputStr && !full) { + const e = document.createElement('div'); e.className = 'v2-act-block-label'; e.textContent = 'no details'; + inner.append(e); + } } + function buildToolCard(act, done) { + const card = document.createElement('div'); + card.className = 'v2-act-tool' + (done ? (act.is_error ? ' done err' : ' done') : ''); + const head = document.createElement('button'); + head.className = 'v2-act-tool-head'; head.type = 'button'; + head.setAttribute('aria-expanded', 'false'); + const ic = document.createElement('span'); ic.className = 'v2-act-ic'; + ic.innerHTML = done ? (act.is_error ? '⚠' : '✓') : ''; + const label = document.createElement('span'); label.className = 'v2-act-label'; + label.textContent = act.label || act.name || 'tool'; + const sum = document.createElement('span'); sum.className = 'v2-act-summary'; + sum.textContent = done ? (act.summary || '') : ''; + const chev = document.createElement('span'); chev.className = 'v2-act-chev'; chev.textContent = '›'; + head.append(ic, label, sum, chev); + const body = document.createElement('div'); body.className = 'v2-act-tool-body'; + fillToolBody(body, act); + head.addEventListener('click', () => { + const open = card.classList.toggle('open'); + head.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + card.append(head, body); + return card; + } + function updateToolCard(card, act) { + card.classList.add('done'); + if (act.is_error) card.classList.add('err'); + const ic = card.querySelector('.v2-act-ic'); if (ic) ic.textContent = act.is_error ? '⚠' : '✓'; + const sum = card.querySelector('.v2-act-summary'); if (sum) sum.textContent = act.summary || ''; + const body = card.querySelector('.v2-act-tool-body'); if (body) fillToolBody(body, act); + } + function captureCampaignId(text) { + if (!text) return; + const s = String(text); + const m = s.match(/campaign_id[=:\s]+([0-9a-f]{6,})/i) || s.match(/\(id:\s*([0-9a-f]{6,})/i); + if (m) capturedCampaignId = m[1]; + } + + function applyActivity(act) { + if (!planActive() || !act) return; + const f = feedEl(); if (!f) return; + switch (act.kind) { + case 'turn_start': + feedTextEl = null; hidePlanError(); clearFallback(); showThinking(true); + break; + case 'thinking': + showThinking(true); + break; + case 'text': { + const chunk = act.text || ''; + if (!chunk) break; + if (!feedTextEl) { + feedTextEl = document.createElement('div'); + feedTextEl.className = 'v2-act-text'; + feedTextEl._raw = ''; + f.appendChild(feedTextEl); + } + feedTextEl._raw += chunk; + feedTextEl.innerHTML = renderMd(feedTextEl._raw); + feedHadContent = true; showThinking(true); scrollFeedIfNearBottom(); + break; + } + case 'tool_start': { + // ask_user_choice IS the active question (rendered separately in + // #v2-plan-ask) — don't also show it as a feed card. + if (act.name === 'ask_user_choice') break; + feedTextEl = null; + const card = buildToolCard(act, false); + f.appendChild(card); + (runningTools[act.name] = runningTools[act.name] || []).push(card); + feedHadContent = true; showThinking(true); scrollFeedIfNearBottom(); + break; + } + case 'tool_result': { + captureCampaignId(act.summary); + captureCampaignId(act.full); + if (PLAN_TOOLS.has(act.name)) schedulePlanRefresh(); + if (act.name === 'ask_user_choice') break; + feedTextEl = null; + const arr = runningTools[act.name] || []; + const card = arr.pop(); + if (card) updateToolCard(card, act); + else f.appendChild(buildToolCard(act, true)); + feedHadContent = true; scrollFeedIfNearBottom(); + break; + } + case 'turn_end': + showThinking(false); feedTextEl = null; + refreshPlanPanel(); + if (!current && !feedHadContent) showFallback(); + break; + case 'turn_error': + showPlanError(act.error || 'Something went wrong — open the conversation for detail.'); + break; + } + } + + function showFallback() { + const f = feedEl(); if (!f || f.querySelector('.v2-plan-fallback')) return; + const d = document.createElement('div'); + d.className = 'v2-plan-fallback'; + d.innerHTML = 'Gently replied in prose — open the conversation to read it.'; + d.querySelector('a').addEventListener('click', openChat); + f.appendChild(d); + } + + // ── THE PLAN panel: mirror the real campaign tree ────────────────── + async function refreshPlanPanel() { + try { + let tree = null; + if (capturedCampaignId) { + const r = await fetch(`/api/campaigns/${encodeURIComponent(capturedCampaignId)}/tree`); + if (r.ok) tree = await r.json(); + } + if (!tree) { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); tree = (d.campaigns || [])[0] || null; } + } + if (tree) renderPlanTree(tree); + } catch (e) { /* keep whatever is shown */ } + } + function planName(c) { + c = c || {}; + return c.shorthand || c.display_name || c.description || 'Plan'; + } + function renderPlanTree(tree) { + const list = $('v2-plan-summary'); + if (!list || !tree) return; + const phases = tree.children || []; + const rootItems = tree.items || []; + if (!phases.length && !rootItems.length) return; // nothing to show yet — keep placeholder + list.innerHTML = ''; + const title = document.createElement('div'); + title.className = 'v2-plan-title-row'; + title.textContent = planName(tree.campaign); + list.appendChild(title); + const addTask = (parent, it) => { + const t = document.createElement('div'); + t.className = 'v2-plan-task'; + t.textContent = it.title || it.shorthand || '(task)'; + parent.appendChild(t); + }; + rootItems.forEach(it => addTask(list, it)); + phases.forEach(phase => { + if (!phase) return; + const wrap = document.createElement('div'); + wrap.className = 'v2-plan-phase'; + const h = document.createElement('div'); + h.className = 'v2-plan-phase-h'; + h.textContent = planName(phase.campaign); + wrap.appendChild(h); + (phase.items || []).forEach(it => addTask(wrap, it)); + list.appendChild(wrap); + }); + } + + // ── ask rendering (the active question) ──────────────────────────── function labelFor(data, sel) { const opts = (data && data.options) || []; const one = (s) => { const o = opts.find(o => o && (o.id === s || o.value === s || o.label === s)); return o ? o.label : String(s); }; - if (Array.isArray(sel)) return sel.map(one).join(', '); - return one(sel); + return Array.isArray(sel) ? sel.map(one).join(', ') : one(sel); } function recordPick(data, sel) { const list = $('v2-plan-summary'); if (!list) return; const empty = list.querySelector('.v2-plan-side-empty'); if (empty) empty.remove(); + const matched = (data && data.options || []).some(o => o && (o.id === sel || o.value === sel || o.label === sel)); const row = document.createElement('div'); - row.className = 'v2-plan-row'; + row.className = 'v2-plan-row' + (matched ? '' : ' v2-plan-row-freetext'); row.innerHTML = ''; row.querySelector('.k').textContent = (data && data.question) || 'Choice'; row.querySelector('.v').textContent = labelFor(data, sel); @@ -84,7 +310,7 @@ const V2Landing = (() => { function renderAsk() { const mount = $('v2-plan-ask'); if (!mount || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; - showThinking(false); + showThinking(false); hidePlanError(); clearFallback(); const data = current.data, reqId = current.reqId; const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; const card = AgentChat.buildAskCard(data, { @@ -92,39 +318,104 @@ const V2Landing = (() => { onPick: (sel) => { recordPick(data, sel); AgentChat.answerChoice(reqId, sel); - current = null; - clearAsk(); - showThinking(true); // agent computing the next step + current = null; clearAsk(); showThinking(true); }, }); mount.innerHTML = ''; mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); } - function startPlan() { - setScreen('plan'); // stay on the overlay; swap welcome → plan wizard - resetSummary(); - clearAsk(); + let planKickedOff = false; // guard: design-kickoff fires once per session + async function startPlan() { + setScreen('plan'); + // Re-entering the wizard (Back → Plan again) must NOT re-fire the + // kickoff — that stacked duplicate "/plan" + design turns. Just show + // the wizard with its existing state. + if (planKickedOff) return; + planKickedOff = true; + resetSummary(); clearAsk(); clearActivity(); current = null; showThinking(true); - // /plan deterministically enters plan mode; the kickoff draws out the - // first question, which the agent asks via ask_user_choice (its prompt - // mandates it) → renders here, not in chat. runCommand connects on its - // own and flushes both in order without opening the chat panel. - if (typeof AgentChat !== 'undefined' && AgentChat.runCommand) { - AgentChat.runCommand('/plan'); + // Campaigns are persistent agent memory (not session state), so the + // agent always builds on an existing one — which leaves a user wanting a + // fresh plan stuck. So if an active campaign exists, ask up front: + // continue it (the default) or start a brand-new one. With NO campaign + // there's nothing to continue, so skip the gate and design straight away + // (that path is fresh anyway). + let campaign = null; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); campaign = (d.campaigns || [])[0] || null; } + } catch (e) { /* offline / no API — just design */ } + if (campaign) renderCampaignChoice(campaign); + else kickoffDesign('continue'); + } + + // Enter plan mode, then prompt design. The prompt differs by intent: build + // on the active campaign, or set it aside and create a new one. A free-typed + // answer from the choice card becomes the design brief directly. + function kickoffDesign(mode) { + showThinking(true); + if (typeof AgentChat === 'undefined' || !AgentChat.runCommand) return; + AgentChat.runCommand('/plan'); + if (mode === 'fresh') { + AgentChat.runCommand( + "I want to start a brand-new experiment, not continue any existing " + + "campaign. Create a new campaign and let's design it from scratch — " + + "what should we capture?" + ); + } else if (mode === 'continue') { AgentChat.runCommand("Let's design this run — what should it capture?"); + } else { + // free text from the choice card's "Something else…" escape + AgentChat.runCommand(String(mode)); } } + // Continue-vs-fresh gate, shown only when an active campaign exists. Reuses + // the agent ask-card styling so it's visually identical to the agent's own + // questions; picking routes into kickoffDesign rather than the agent bridge. + function renderCampaignChoice(tree) { + const mount = $('v2-plan-ask'); + if (!mount || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) { + kickoffDesign('continue'); + return; + } + showThinking(false); hidePlanError(); clearFallback(); + const name = planName((tree && tree.campaign) || {}); + const data = { + question: `You have an active campaign — **${name}**. Design the next run inside it, or start something new?`, + options: [ + { id: 'continue', label: `Continue ${name}`, description: 'Design the next run inside your existing campaign' }, + { id: 'fresh', label: 'Start a brand-new campaign', description: 'Set the existing plan aside and plan from scratch' }, + ], + }; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId: 'landing-campaign-choice', isWake: false, hasControl, + onPick: (sel) => { clearAsk(); kickoffDesign(sel); }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); + } + function openScope() { dismiss(); if (typeof switchTab === 'function') switchTab('devices'); } - + function openChat() { + dismiss(); + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + setTimeout(() => AgentChat.togglePanel(true), 300); + } + } function sendFreeform(text) { const v = (text || '').trim(); - dismiss(); // free-text is the chat last-resort → recede, then open chat + dismiss(); if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { AgentChat.togglePanel(true); if (v && AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(v), 300); @@ -136,47 +427,40 @@ const V2Landing = (() => { if (!el || typeof ClientEventBus === 'undefined') return; // flag off → no-op greet(); - // welcome choices el.querySelectorAll('[data-landing]').forEach(btn => btn.addEventListener('click', () => { const kind = btn.dataset.landing; if (kind === 'plan') startPlan(); else if (kind === 'standalone') openScope(); })); - // welcome escape field const esc = $('v2-escape'), escToggle = $('v2-escape-toggle'), escInput = $('v2-escape-input'), escSend = $('v2-escape-send'); if (escToggle && esc && escInput) { escToggle.addEventListener('click', () => { const open = esc.classList.toggle('open'); + escToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); if (open) setTimeout(() => escInput.focus(), 120); }); const submit = () => sendFreeform(escInput.value); if (escSend) escSend.addEventListener('click', submit); escInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } - else if (e.key === 'Escape') { e.stopPropagation(); esc.classList.remove('open'); } + else if (e.key === 'Escape') { e.stopPropagation(); esc.classList.remove('open'); escToggle.setAttribute('aria-expanded', 'false'); } }); } const skip = $('v2-landing-skip'); if (skip) skip.addEventListener('click', dismiss); - // plan-screen controls const back = $('v2-plan-back'); if (back) back.addEventListener('click', () => setScreen('welcome')); const planChat = $('v2-plan-chat'); - if (planChat) planChat.addEventListener('click', () => { - dismiss(); // chat panel lives under the overlay → recede first - if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { - setTimeout(() => AgentChat.togglePanel(true), 300); - } - }); + if (planChat) planChat.addEventListener('click', openChat); const cont = $('v2-plan-continue'); if (cont) cont.addEventListener('click', dismiss); - // The agent's questions render in the plan stage while it's active; once - // we've receded into the workspace, AskStage (#ask-stage) takes over. + // The agent's questions + work render in the plan stage while it's active; + // once we've receded into the workspace, AskStage (#ask-stage) takes over. ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { if (!planActive()) return; current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; @@ -184,12 +468,12 @@ const V2Landing = (() => { }); ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { if (request_id === '*' || (current && request_id === current.reqId)) { - current = null; - clearAsk(); - if (planActive()) showThinking(true); + current = null; clearAsk(); + if (planActive() && !errorVisible()) showThinking(true); } }); ClientEventBus.on('AGENT_CONTROL', () => { if (current && planActive()) renderAsk(); }); + ClientEventBus.on('AGENT_ACTIVITY', (act) => applyActivity(act)); document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !el || el.classList.contains('dismissed')) return; diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index c972dc3d..36688f8a 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -53,7 +53,7 @@

Take a quick look

-
+
thinking through the next step…
+
- + +
From 1cefe2ffc51cfa44b954c00f5da0c8fb56dc89ed Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 13:18:53 +0530 Subject: [PATCH 018/200] UX v2: agent-activity events + GFM markdown rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-chat.js mirrors the agent stream onto a new AGENT_ACTIVITY event (turn/thinking/text/tool_start/tool_result/turn_end/error) so the plan wizard can render the agent's work as collapsible tool cards instead of leaving it in the chat. Replaces the inline-only mdToHtml with a block-aware, escape-first GFM renderer (headings, pipe tables, lists, fenced code, links — XSS-safe and streaming-safe) and exports it; agent-chat.css styles the ac-md-* output for both the chat and the wizard feed. Co-Authored-By: Claude Fable 5 --- gently/ui/web/static/css/agent-chat.css | 39 ++++++++++++++++++++++++ gently/ui/web/static/js/agent-chat.js | Bin 51841 -> 63820 bytes 2 files changed, 39 insertions(+) diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index fdaaa8e3..29d64646 100644 --- a/gently/ui/web/static/css/agent-chat.css +++ b/gently/ui/web/static/css/agent-chat.css @@ -441,3 +441,42 @@ body.chat-docked .agent-chat:not(.open) { color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1; } .ac-queue-remove:hover { color: var(--color-danger, #f87171); } + +/* ── Rendered markdown (mdToHtml output, ac-md-* classes) ────────────────── + Shared by the chat transcript and the ux_v2 plan-wizard activity feed — the + same renderer feeds both, so these styles cover headings, lists, tables, + code blocks, quotes and links the agent emits. */ +.ac-md { line-height: 1.55; } +.ac-md > :first-child { margin-top: 0; } +.ac-md > :last-child { margin-bottom: 0; } +.ac-md-h1, .ac-md-h2, .ac-md-h3, .ac-md-h4, .ac-md-h5, .ac-md-h6 { + margin: 14px 0 6px; font-weight: 650; line-height: 1.3; letter-spacing: -.01em; color: var(--text); +} +.ac-md-h1 { font-size: 1.25em; } +.ac-md-h2 { font-size: 1.15em; } +.ac-md-h3 { font-size: 1.05em; } +.ac-md-h4, .ac-md-h5, .ac-md-h6 { font-size: 1em; } +.ac-md-p { margin: 7px 0; } +.ac-md-ul, .ac-md-ol { margin: 7px 0; padding-left: 22px; } +.ac-md-li { margin: 3px 0; } +.ac-md-quote { + margin: 8px 0; padding: 4px 12px; border-left: 3px solid var(--border, #e4e9f0); + color: var(--text-muted); font-style: italic; +} +.ac-md-hr { border: 0; border-top: 1px solid var(--border, #e4e9f0); margin: 12px 0; } +.ac-md-link { color: var(--accent, #2f6df6); text-decoration: underline; text-underline-offset: 2px; } +.ac-md-pre { + margin: 8px 0; padding: 10px 12px; border-radius: 8px; overflow-x: auto; + background: var(--bg, #f6f8fb); border: 1px solid var(--border, #e4e9f0); +} +.ac-md-pre .ac-md-code-block, .ac-md-pre code { + font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; + color: var(--text); background: none; padding: 0; white-space: pre; +} +/* GFM tables — wrapped so a wide table scrolls instead of blowing out the column */ +.ac-md-table-wrap { margin: 9px 0; overflow-x: auto; border: 1px solid var(--border, #e4e9f0); border-radius: 8px; } +.ac-md-table { border-collapse: collapse; width: 100%; font-size: 12.5px; } +.ac-md-table th, .ac-md-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border, #e4e9f0); border-right: 1px solid var(--border, #e4e9f0); } +.ac-md-table th:last-child, .ac-md-table td:last-child { border-right: 0; } +.ac-md-table tr:last-child td { border-bottom: 0; } +.ac-md-table thead th { background: var(--bg, #f6f8fb); font-weight: 650; color: var(--text); } diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 33dcf2b37908d8f5cf612d0a59c73cde286e66cd..0e8514df3e603df1220675ba2cff1c2ce566e7d4 100644 GIT binary patch delta 10374 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 delta 434 zcmX@}iMeqq^9FxzPc1D4-^{$s+{7Fmh2q4tRE6BcqU@CX@;obr)Z*mCf>edH%%b8F z9fgvN)I5bmg|yUih0MI1%)C^ElA^@C;KYc2&SfGPk{dPS)PIf==s8u|$u(Qyf}+L{Ua={gGPHp%%Zsdg%cHu_+O zIw9RLS}|Hc1DLg9wI?qV*Nnkx1y~7Cg=UNv#FErpuoXaxumyP#ElEXolNGCkSwI$S z{%3YocJhW;_R0HlcqbPGvTZg9-M|PJxDd`h*)}{B#@~D)d@V>)ck`>rFy_r0VtqM) oJdMq!>E{u83IaJcdu66FZDz^o^_e_>F6U;ST^H;otNm010GiyCBLDyZ From 64f9338398444cf9a00164bcad5f875daf46e0c3 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 13:19:13 +0530 Subject: [PATCH 019/200] Models: migrate to Fable 5 / Opus 4.8 / Sonnet 4.6 with refusal+400 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.py tiers: main→claude-fable-5, perception+medium→claude-opus-4-8, fast→claude-sonnet-4-6, plus a refusal_fallback (claude-opus-4-8). Strip the params the new models reject: thinking budget_tokens → output_config.effort (conversation.py, sam_detection.py); drop the obsolete interleaved-thinking beta header (agent.py). conversation.py: a main-tier 400 (e.g. Fable 5 under <30-day org data retention) OR a stop_reason='refusal' transparently retries the turn on Opus 4.8 in both the streaming and non-streaming paths; get_tool_call guards empty refusal content; dopaminergic detector guarded. chat.py model centralized to settings. Quiet log noise: the per-response diagnostic WARNING→DEBUG (it fired on every tool-use turn), and the benign send-after-close websocket WARNING→DEBUG. Co-Authored-By: Claude Fable 5 --- gently/app/agent.py | 505 ++++++++------------ gently/app/detectors/dopaminergic_signal.py | 8 +- gently/hardware/dispim/sam_detection.py | 471 ++++++++---------- gently/harness/conversation.py | 458 +++++++++--------- gently/settings.py | 54 ++- gently/ui/web/connection_manager.py | 5 +- gently/ui/web/routes/chat.py | 4 +- 7 files changed, 701 insertions(+), 804 deletions(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index 4a5f6602..40618718 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -14,41 +14,38 @@ import asyncio import logging import os -from collections.abc import Callable +from typing import Dict, List, Optional, Callable, Any, TYPE_CHECKING from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any import anthropic import numpy as np -from ..exceptions import StorageError +from ..exceptions import StorageError, AgentError from ..settings import settings if TYPE_CHECKING: from ..ui.web.server import VisualizationServer -from gently_perception import Perceiver -from ..core import EventType, emit, get_event_bus -from ..core.file_store import FileStore -from ..harness.conversation import ConversationManager -from ..harness.orchestration.plan_synthesis import ( - PlanLibrary, - PlanSynthesizer, - PlanValidator, -) -from ..harness.prompts.manager import PromptManager -from ..harness.session.interaction_logger import InteractionLogger -from ..harness.session.manager import SessionManager -from ..harness.session.timeline import TimelineManager -from ..harness.state import ExperimentState +logger = logging.getLogger(__name__) + +from ..harness.state import ExperimentState, EmbryoState, ImageRecord +from ..harness.orchestration.plan_synthesis import PlanSynthesizer, PlanLibrary, PlanValidator from ..harness.tools.registry import get_tool_registry +from gently_perception import Perceiver # Import tools package to trigger @tool decorator registration from . import tools as _tools # noqa: F401 +from ..harness.session.interaction_logger import InteractionLogger from .orchestration.timelapse import TimelapseOrchestrator +from ..harness.session.timeline import TimelineManager +from ..core import EventType, get_event_bus, emit +from ..core.file_store import FileStore + +from ..harness.conversation import ConversationManager +from ..harness.session.manager import SessionManager +from ..harness.prompts.manager import PromptManager -logger = logging.getLogger(__name__) # Shown when the agent is launched in UI-only mode (--no-api). The web UI is # fully browsable, but anything that would call Claude is disabled. @@ -74,7 +71,7 @@ class MicroscopyAgent: def __init__( self, - api_key: str | None = None, + api_key: Optional[str] = None, storage_path: Path = Path("./experiment_data"), model: str = settings.models.main, microscope_client=None, @@ -112,12 +109,11 @@ def __init__( # the message entry points refuse to call Claude. self.api_enabled = not no_api - # API client with interleaved thinking support + # Shared API client. No interleaved-thinking beta header: it's GA on the + # 4.6+ models and obsolete on Fable 5 (always-on thinking); the header is + # dropped so it can't conflict with the new model family. self.claude = anthropic.Anthropic( - api_key=api_key - or os.getenv("ANTHROPIC_API_KEY") - or ("no-api-mode" if no_api else None), - default_headers={"anthropic-beta": "interleaved-thinking-2025-05-14"}, + api_key=api_key or os.getenv("ANTHROPIC_API_KEY") or ("no-api-mode" if no_api else None), ) self.model = model @@ -129,7 +125,7 @@ def __init__( self.mode: str = "run" # Context store (agent's mind — set via set_context_store) - self.context_store: Any | None = None + self.context_store: Optional[Any] = None # Experiment state self.experiment = ExperimentState() @@ -145,7 +141,8 @@ def __init__( # Plan synthesis self.plan_synthesizer = PlanSynthesizer( - plan_library=PlanLibrary(), validator=PlanValidator() + plan_library=PlanLibrary(), + validator=PlanValidator() ) # Event bus for async messaging (must be before perception manager) @@ -166,8 +163,8 @@ def __init__( self.client = self.microscope # Callbacks - self.on_message_callback: Callable | None = None - self.choice_handler: Callable | None = None + self.on_message_callback: Optional[Callable] = None + self.choice_handler: Optional[Callable] = None # Serializes conversation turns: user turns and autonomous wake turns # must not interleave on the shared conversation_history. @@ -178,18 +175,14 @@ def __init__( # human). User turns are unaffected. _wake_choice_factory is set by the # web bridge so ASK-mode wake turns can round-trip an approval picker. self._autonomous_active = False - self._autonomous_blocked_tools = frozenset( - { - "set_laser_power", - "remove_embryo", - "stop_timelapse", - } - ) + self._autonomous_blocked_tools = frozenset({ + "set_laser_power", "remove_embryo", "stop_timelapse", + }) self._wake_choice_factory = None self._wake_choice_discard = None # Interaction logger for structured logging (research data collection) - self.interaction_logger: InteractionLogger | None = None + self.interaction_logger: Optional[InteractionLogger] = None # Event capture — durable log of every EventBus event during this # session. Substrate for offline replay / shadow-mode A/B of @@ -202,13 +195,13 @@ def __init__( self.decision_log = None # Timelapse orchestrator (initialized when microscope connected) - self.timelapse_orchestrator: TimelapseOrchestrator | None = None + self.timelapse_orchestrator: Optional[TimelapseOrchestrator] = None # Timeline manager for tracking events - self.timeline_manager: TimelineManager | None = None + self.timeline_manager: Optional[TimelineManager] = None # Visualization server for real-time feedback - self.viz_server: VisualizationServer | None = None + self.viz_server: Optional["VisualizationServer"] = None # Device-state monitor (bridges device-layer SSE → EventBus) self.device_state_monitor = None @@ -226,10 +219,10 @@ def __init__( ) # Wire tool execution context self.conversation._tool_context = { - "agent": self, - "client": getattr(self, "microscope", None), - "microscope": getattr(self, "microscope", None), - "databroker": getattr(self, "databroker", None), + 'agent': self, + 'client': getattr(self, 'microscope', None), + 'microscope': getattr(self, 'microscope', None), + 'databroker': getattr(self, 'databroker', None), } # Session manager (persistence) @@ -249,22 +242,16 @@ def __init__( success, history = self.sessions._resume_session(session_id, self.experiment) if success: self.conversation.conversation_history = history - self._emit_event( - EventType.SESSION_RESTORED, - { - "session_id": session_id, - "embryo_count": len(self.experiment.embryos), - "message_count": len(self.conversation.conversation_history), - }, - ) + self._emit_event(EventType.SESSION_RESTORED, { + 'session_id': session_id, + 'embryo_count': len(self.experiment.embryos), + 'message_count': len(self.conversation.conversation_history), + }) else: self.sessions.create_session() - self._emit_event( - EventType.SESSION_STARTED, - { - "session_id": self.sessions.session_id, - }, - ) + self._emit_event(EventType.SESSION_STARTED, { + 'session_id': self.sessions.session_id, + }) # Initialize interaction logger (for research data collection) self._init_interaction_logger() @@ -297,7 +284,6 @@ def __init__( # autonomously; enabled via the set_autonomy tool. try: from gently.app.wake_router import WakeRouter - self.wake_router = WakeRouter(self, self._event_bus) except Exception: logger.exception("Failed to init wake-router") @@ -314,7 +300,7 @@ def session_id(self) -> str: return self.sessions.session_id @property - def _session_id(self) -> str | None: + def _session_id(self) -> Optional[str]: """Internal session ID (backward compat).""" return self.sessions._session_id @@ -323,7 +309,7 @@ def _session_id(self, value): self.sessions._session_id = value @property - def conversation_history(self) -> list[dict]: + def conversation_history(self) -> List[Dict]: """Get conversation history.""" return self.conversation.conversation_history @@ -368,7 +354,6 @@ def set_context_store(self, context_store) -> None: self.prompts.context_store = context_store # Create agent memory harness from ..harness.memory.interface import AgentMemory - self.memory = AgentMemory(context_store, session_id=self.session_id) self.prompts.memory = self.memory @@ -378,13 +363,8 @@ def enter_plan_mode(self) -> str: return "Already in plan mode." self.mode = "plan" import gently.harness.plan_mode.tools # noqa: F401 - self._update_system_prompt() - emit( - EventType.STATUS_CHANGED, - {"field": "agent_mode", "value": "plan"}, - source="agent", - ) + emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "plan"}, source="agent") logger.info("Entered plan mode") return "Switched to plan mode. I'm now your experimental design collaborator." @@ -462,8 +442,7 @@ def exit_plan_mode(self) -> str: if item and self.session_id and self.context_store: try: self.context_store.link_session_campaign( - self.session_id, - item.campaign_id, + self.session_id, item.campaign_id, ) except Exception: pass @@ -480,11 +459,7 @@ def exit_plan_mode(self) -> str: self.prompts.invalidate_context_cache() self._update_system_prompt() - emit( - EventType.STATUS_CHANGED, - {"field": "agent_mode", "value": "run"}, - source="agent", - ) + emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "run"}, source="agent") logger.info("Exited plan mode") return result @@ -493,14 +468,11 @@ def exit_plan_mode(self) -> str: def _update_system_prompt(self, context_summary: str | None = None): """Rebuild system prompt via PromptManager.""" self.system_prompt = self.prompts.update_system_prompt( - self.experiment, - self.client, - self.mode, - context_summary, + self.experiment, self.client, self.mode, context_summary, perceiver=getattr(self, "perceiver", None), ) - def _get_active_plan_summary(self) -> str | None: + def _get_active_plan_summary(self) -> Optional[str]: """Delegation shim for agent bridge access.""" return self.prompts.get_active_plan_summary() @@ -530,7 +502,7 @@ def _auto_save(self): self.experiment, self.conversation.conversation_history, self.system_prompt ) - def list_sessions(self) -> list[dict]: + def list_sessions(self) -> List[Dict]: """List available sessions.""" return self.sessions.list_sessions() @@ -563,7 +535,6 @@ def _init_event_capture(self): a stripped-down agent) — replay just won't have a log to read. """ from gently.eval import EventCapture - try: session_dir = None sid = self.session_id @@ -571,8 +542,7 @@ def _init_event_capture(self): session_dir = self.store._session_dir(sid) if session_dir is None: logging.getLogger(__name__).debug( - "EventCapture: no session dir for %s — skipping", sid - ) + "EventCapture: no session dir for %s — skipping", sid) return path = session_dir / "events.jsonl" self.event_capture = EventCapture(path) @@ -599,7 +569,6 @@ def _init_decision_log(self): logs and the two are diffed offline. """ from gently.eval import DecisionLog - try: session_dir = None sid = self.session_id @@ -607,8 +576,7 @@ def _init_decision_log(self): session_dir = self.store._session_dir(sid) if session_dir is None: logging.getLogger(__name__).debug( - "DecisionLog: no session dir for %s — skipping", sid - ) + "DecisionLog: no session dir for %s — skipping", sid) return path = session_dir / "decisions.jsonl" self.decision_log = DecisionLog(path) @@ -706,15 +674,12 @@ def on_stage_detected(event): embryo_id = data.get("embryo_id") if embryo_id and embryo_id in self.experiment.embryos: embryo = self.experiment.embryos[embryo_id] - embryo.add_cv_result( - "stage_classification", - { - "stage": data.get("stage"), - "confidence": data.get("confidence"), - "nuclei_count": data.get("nuclei_count"), - "timepoint": data.get("timepoint"), - }, - ) + embryo.add_cv_result("stage_classification", { + "stage": data.get("stage"), + "confidence": data.get("confidence"), + "nuclei_count": data.get("nuclei_count"), + "timepoint": data.get("timepoint"), + }) except Exception as e: logger.warning(f"Error handling stage detected event: {e}") @@ -737,34 +702,23 @@ def on_perception(event): stage = data.get("stage") # 'no_object' is an empty-field sentinel, not a developmental # stage — don't mirror it into latest_developmental_stage. - if ( - not stage - or stage == "no_object" - or not embryo_id - or embryo_id not in self.experiment.embryos - ): + if (not stage or stage == "no_object" or not embryo_id + or embryo_id not in self.experiment.embryos): return embryo = self.experiment.embryos[embryo_id] if stage == getattr(embryo, "latest_developmental_stage", None): return # steady state — nothing new to mirror - embryo.add_cv_result( - "stage_classification", - { - "stage": stage, - "timepoint": data.get("timepoint"), - "stability": data.get("stability"), - "temporal_analysis": data.get("temporal_analysis"), - "detector_name": "perception", - }, - ) + embryo.add_cv_result("stage_classification", { + "stage": stage, + "timepoint": data.get("timepoint"), + "stability": data.get("stability"), + "temporal_analysis": data.get("temporal_analysis"), + "detector_name": "perception", + }) self.invalidate_context_cache() self._auto_save() - logger.info( - "Perception: %s -> stage %s (t%s)", - embryo_id, - stage, - data.get("timepoint"), - ) + logger.info("Perception: %s -> stage %s (t%s)", + embryo_id, stage, data.get("timepoint")) except Exception as e: logger.warning(f"Error handling perception event: {e}") @@ -779,9 +733,7 @@ def on_perception(event): # ===== Visualization Server Methods ===== - async def start_viz_server( - self, port: int = settings.network.viz_port, ssl_certfile=None, ssl_keyfile=None - ): + async def start_viz_server(self, port: int = settings.network.viz_port, ssl_certfile=None, ssl_keyfile=None): """Start the visualization server for real-time feedback.""" if self.viz_server is not None: logger.info("Visualization server already running") @@ -831,7 +783,6 @@ async def start_viz_server( if self.microscope is not None and self.device_state_monitor is None: try: from .device_state_monitor import DeviceStateMonitor - self.device_state_monitor = DeviceStateMonitor(self.microscope) await self.device_state_monitor.start() logger.info("Device-state monitor started") @@ -845,7 +796,6 @@ async def start_viz_server( if self.microscope is not None and self.bottom_camera_monitor is None: try: from .bottom_camera_monitor import BottomCameraStreamMonitor - self.bottom_camera_monitor = BottomCameraStreamMonitor(self.microscope) logger.info("Bottom-camera monitor ready (not started)") except Exception as e: @@ -876,14 +826,16 @@ def push_viz( array: np.ndarray, uid: str, data_type: str = "image", - metadata: dict[str, Any] | None = None, + metadata: Optional[Dict[str, Any]] = None, ): """Non-blocking push of image to visualization server.""" if self.viz_server is None: return try: - asyncio.create_task(self.viz_server.push_image(array, uid, data_type, metadata or {})) + asyncio.create_task( + self.viz_server.push_image(array, uid, data_type, metadata or {}) + ) except RuntimeError: pass except Exception as e: @@ -902,7 +854,7 @@ def _has_microscope(self) -> bool: """ return self.client is not None - def _emit_event(self, event_type: EventType, data: dict | None = None): + def _emit_event(self, event_type: EventType, data: Optional[Dict] = None): """Emit an event to the event bus.""" self._event_bus.publish( event_type=event_type, @@ -943,13 +895,10 @@ def _publish_embryos_update(self) -> None: def _mark_significant_action(self, action_type: str): """Mark that a significant action occurred (triggers auto-save).""" self._auto_save() - self._emit_event( - EventType.SESSION_SAVED, - { - "session_id": self.sessions._session_id, - "action_type": action_type, - }, - ) + self._emit_event(EventType.SESSION_SAVED, { + 'session_id': self.sessions._session_id, + 'action_type': action_type, + }) # ===== Public Message API ===== @@ -968,11 +917,8 @@ async def handle_message(self, user_message: str) -> str: Response from agent """ if quick_response := self.conversation.try_quick_response( - user_message, - self.experiment, - self.mode, - self.enter_plan_mode, - self.exit_plan_mode, + user_message, self.experiment, self.mode, + self.enter_plan_mode, self.exit_plan_mode, ): return quick_response @@ -986,7 +932,10 @@ async def handle_message(self, user_message: str) -> str: self._update_system_prompt(context_summary) # Add user message to history - self.conversation.conversation_history.append({"role": "user", "content": user_message}) + self.conversation.conversation_history.append({ + "role": "user", + "content": user_message + }) tools = self._get_tools_for_mode() cached_prompt = self._get_cached_system_prompt() @@ -1013,17 +962,14 @@ async def handle_message_stream(self, user_message: str): Chunks with 'type' and data """ if quick_response := self.conversation.try_quick_response( - user_message, - self.experiment, - self.mode, - self.enter_plan_mode, - self.exit_plan_mode, + user_message, self.experiment, self.mode, + self.enter_plan_mode, self.exit_plan_mode, ): - yield {"type": "text", "text": quick_response} + yield {'type': 'text', 'text': quick_response} return if not self.api_enabled: - yield {"type": "text", "text": _NO_API_NOTICE} + yield {'type': 'text', 'text': _NO_API_NOTICE} return # Hold the turn-lock for the whole streamed turn so an autonomous wake @@ -1039,14 +985,16 @@ async def handle_message_stream(self, user_message: str): ) self._update_system_prompt(context_summary) - self.conversation.conversation_history.append({"role": "user", "content": user_message}) + self.conversation.conversation_history.append({ + "role": "user", + "content": user_message + }) tools = self._get_tools_for_mode() cached_prompt = self._get_cached_system_prompt() inner_gen = self.conversation.call_claude_stream( - cached_prompt, - tools, + cached_prompt, tools, tool_label_fn=self.conversation.tool_label, auto_save_fn=self._auto_save, ) @@ -1141,11 +1089,8 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): choice_data = chunk.get("choice_data", {}) if isinstance(chunk, dict) else {} factory = getattr(self, "_wake_choice_factory", None) if not interactive or factory is None: - logger.info( - "Wake picker auto-cancelled (interactive=%s, channel=%s)", - interactive, - factory is not None, - ) + logger.info("Wake picker auto-cancelled (interactive=%s, channel=%s)", + interactive, factory is not None) return "cancelled" try: future = factory(choice_data) # registers future + sets request_id @@ -1155,7 +1100,6 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): request_id = choice_data.get("request_id", "") await emit({**chunk, "origin": "wake", "request_id": request_id}) from gently.app.wake_router import ASK_TIMEOUT_SEC - try: selected = await asyncio.wait_for(future, timeout=ASK_TIMEOUT_SEC) except asyncio.TimeoutError: @@ -1178,7 +1122,7 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): pass return selected or "skip" - async def get_tool_call(self, user_message: str) -> dict | None: + async def get_tool_call(self, user_message: str) -> Optional[Dict]: """Dry-run tool call (for benchmarking).""" context_summary = await self.prompts.get_cached_context_summary( self.experiment, self.timelapse_orchestrator, self.timeline_manager @@ -1190,25 +1134,25 @@ async def get_tool_call(self, user_message: str) -> dict | None: # === Experiment Management Methods === - def load_embryos_from_database(self, database: dict): + def load_embryos_from_database(self, database: Dict): """Load embryos from calibration database.""" - if "embryos" not in database: + if 'embryos' not in database: return - for embryo_id, embryo_data in database["embryos"].items(): - position = embryo_data.get("stage_position_after_centering_um", {}) - calibration = embryo_data.get("calibration", {}) + for embryo_id, embryo_data in database['embryos'].items(): + position = embryo_data.get('stage_position_after_centering_um', {}) + calibration = embryo_data.get('calibration', {}) self.experiment.add_embryo( embryo_id=embryo_id, position=position, calibration=calibration, - uid=embryo_data.get("uid"), + uid=embryo_data.get('uid'), ) self._update_system_prompt() - def import_embryos_from_session(self, session_id: str, clear_existing: bool = False) -> dict: + def import_embryos_from_session(self, session_id: str, clear_existing: bool = False) -> Dict: """ Import embryos from another session into the current experiment. @@ -1264,14 +1208,14 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa if not embryo_states: session_data = self.store.load_session_snapshot(session_id) if session_data: - embryo_states = session_data.get("embryo_states", {}) + embryo_states = session_data.get('embryo_states', {}) if not embryo_states: return { - "success": False, - "error": "No embryos found in session", - "imported": [], - "skipped": [], + 'success': False, + 'error': "No embryos found in session", + 'imported': [], + 'skipped': [], } if clear_existing: @@ -1291,30 +1235,30 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa # Prefer explicit coarse/fine when the snapshot has them # (FileStore path); fall back to flat stage_position for the # legacy JSON-snapshot path which only carries the resolved view. - position_coarse = embryo_data.get("position_coarse") - position_fine = embryo_data.get("position_fine") + position_coarse = embryo_data.get('position_coarse') + position_fine = embryo_data.get('position_fine') if position_coarse is None and position_fine is None: - position_coarse = embryo_data.get("stage_position", {}) - calibration = embryo_data.get("calibration", {}) - source_uid = embryo_data.get("uid") or f"{session_id}_{embryo_id}" + position_coarse = embryo_data.get('stage_position', {}) + calibration = embryo_data.get('calibration', {}) + source_uid = embryo_data.get('uid') or f"{session_id}_{embryo_id}" self.experiment.add_embryo( embryo_id=embryo_id, position=position_coarse or {}, position_fine=position_fine or {}, calibration=calibration, - user_label=embryo_data.get("user_label"), + user_label=embryo_data.get('user_label'), uid=source_uid, - role=embryo_data.get("role") or "unassigned", + role=embryo_data.get('role') or 'unassigned', ) embryo = self.experiment.embryos[embryo_id] - embryo.nickname = embryo_data.get("nickname") - embryo.interval_seconds = embryo_data.get("interval_seconds") - embryo.num_slices = embryo_data.get("num_slices", 50) - embryo.exposure_ms = embryo_data.get("exposure_ms", 10.0) - embryo.priority = embryo_data.get("priority", "normal") - embryo.acquisition_mode = embryo_data.get("acquisition_mode", "volume") + embryo.nickname = embryo_data.get('nickname') + embryo.interval_seconds = embryo_data.get('interval_seconds') + embryo.num_slices = embryo_data.get('num_slices', 50) + embryo.exposure_ms = embryo_data.get('exposure_ms', 10.0) + embryo.priority = embryo_data.get('priority', 'normal') + embryo.acquisition_mode = embryo_data.get('acquisition_mode', 'volume') # Light budget import. Prefer fields already on embryo_data # (future schema may persist these directly on embryo.yaml); @@ -1325,22 +1269,27 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa # removed and the import should fail loudly if dose is # missing. dose = self._compute_imported_dose(session_id, embryo_id) - embryo.exposure_count = embryo_data.get("exposure_count") or dose["exposure_count"] + embryo.exposure_count = ( + embryo_data.get('exposure_count') + or dose['exposure_count'] + ) embryo.total_exposure_ms = ( - embryo_data.get("total_exposure_ms") or dose["total_exposure_ms"] + embryo_data.get('total_exposure_ms') + or dose['total_exposure_ms'] ) embryo.timepoints_acquired = ( - embryo_data.get("timepoints_acquired") or dose["exposure_count"] + embryo_data.get('timepoints_acquired') + or dose['exposure_count'] ) - last_imaged_str = embryo_data.get("last_imaged") + last_imaged_str = embryo_data.get('last_imaged') if last_imaged_str: try: embryo.last_imaged = datetime.fromisoformat(last_imaged_str) except (ValueError, TypeError): - embryo.last_imaged = dose["last_imaged"] + embryo.last_imaged = dose['last_imaged'] else: - embryo.last_imaged = dose["last_imaged"] + embryo.last_imaged = dose['last_imaged'] imported.append(embryo_id) @@ -1351,14 +1300,14 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa self._mark_significant_action("embryo_import") return { - "success": len(imported) > 0, - "imported": imported, - "skipped": skipped, - "errors": errors, - "source_session": session_id, + 'success': len(imported) > 0, + 'imported': imported, + 'skipped': skipped, + 'errors': errors, + 'source_session': session_id, } - def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> dict: + def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> Dict: """Reconstruct an embryo's realized 488 nm photodose from the source session's per-volume meta files. @@ -1373,68 +1322,66 @@ def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> dict TODO: replace with reading a persisted ``dose:`` block from embryo.yaml once dose-tracking is first-class. """ + import yaml from datetime import datetime from pathlib import Path - import yaml - result = { - "exposure_count": 0, - "total_exposure_ms": 0.0, - "last_imaged": None, + 'exposure_count': 0, + 'total_exposure_ms': 0.0, + 'last_imaged': None, } if not self.store: return result # FileStore exposes _session_dir(session_id) → resolved Path. - session_dir_fn = getattr(self.store, "_session_dir", None) + session_dir_fn = getattr(self.store, '_session_dir', None) sd = session_dir_fn(source_session_id) if callable(session_dir_fn) else None if sd is None: return result - vols_dir = Path(sd) / "embryos" / embryo_id / "volumes" + vols_dir = Path(sd) / 'embryos' / embryo_id / 'volumes' if not vols_dir.is_dir(): return result latest = None - for meta_path in sorted(vols_dir.glob("*.meta.yaml")): + for meta_path in sorted(vols_dir.glob('*.meta.yaml')): try: doc = yaml.safe_load(meta_path.read_text()) or {} except Exception: continue - md = doc.get("metadata") or {} - num_slices = md.get("num_slices") + md = doc.get('metadata') or {} + num_slices = md.get('num_slices') if num_slices is None: - shape = doc.get("shape") or [] + shape = doc.get('shape') or [] num_slices = shape[0] if shape else 0 - exposure_ms = md.get("exposure_ms") or 0.0 + exposure_ms = md.get('exposure_ms') or 0.0 try: - result["total_exposure_ms"] += float(num_slices) * float(exposure_ms) + result['total_exposure_ms'] += float(num_slices) * float(exposure_ms) except (TypeError, ValueError): pass - result["exposure_count"] += 1 - acq = doc.get("acquired_at") + result['exposure_count'] += 1 + acq = doc.get('acquired_at') if acq and (latest is None or acq > latest): latest = acq if latest: try: - result["last_imaged"] = datetime.fromisoformat(latest) + result['last_imaged'] = datetime.fromisoformat(latest) except (ValueError, TypeError): pass return result - async def on_volume_acquired( - self, embryo_id: str, timepoint: int, volume_data, volume_path=None - ): + async def on_volume_acquired(self, embryo_id: str, timepoint: int, + volume_data, volume_path=None): """Callback when a volume is acquired.""" embryo = self.experiment.embryos.get(embryo_id) if not embryo: return - if hasattr(volume_data, "read_volume"): + if hasattr(volume_data, 'read_volume'): volume = volume_data.read_volume() else: volume = volume_data @@ -1443,8 +1390,7 @@ async def on_volume_acquired( if self.store and self.session_id: try: self.store.register_embryo( - self.session_id, - embryo_id, + self.session_id, embryo_id, position_coarse=embryo.position_coarse or None, position_fine=embryo.position_fine or None, calibration=embryo.calibration, @@ -1461,19 +1407,14 @@ async def on_volume_acquired( } if volume_path is not None: stored_path = self.store.register_volume( - self.session_id, - embryo_id, - timepoint, + self.session_id, embryo_id, timepoint, incoming_path=Path(volume_path), metadata=acq_metadata, volume_data=volume, ) else: stored_path = self.store.put_volume( - self.session_id, - embryo_id, - timepoint, - volume, + self.session_id, embryo_id, timepoint, volume, metadata=acq_metadata, ) except StorageError: @@ -1488,9 +1429,9 @@ async def on_volume_acquired( if self.viz_server and volume is not None: try: from gently.core.imaging import ( - apply_crop_bounds, - compute_crop_bounds, projection_three_view, + compute_crop_bounds, + apply_crop_bounds, ) view_a = volume[0] if volume.ndim == 4 else volume @@ -1498,18 +1439,14 @@ async def on_volume_acquired( if view_a.ndim == 3: z_depth, height, width = view_a.shape if width > height * 2: - view_a = view_a[:, :, : width // 2] + view_a = view_a[:, :, :width // 2] bounds = compute_crop_bounds(view_a) cropped = apply_crop_bounds(view_a, bounds) three_view_img, _ = projection_three_view(cropped) else: three_view_img = view_a.astype(np.float32) if three_view_img.max() > three_view_img.min(): - three_view_img = ( - (three_view_img - three_view_img.min()) - / (three_view_img.max() - three_view_img.min()) - * 255 - ) + three_view_img = (three_view_img - three_view_img.min()) / (three_view_img.max() - three_view_img.min()) * 255 three_view_img = three_view_img.astype(np.uint8) self.push_viz( @@ -1517,32 +1454,29 @@ async def on_volume_acquired( uid=projection_uid, data_type="volume_projection", metadata={ - "embryo_id": embryo_id, - "timepoint": timepoint, - "shape": list(volume.shape), - "projection_uid": projection_uid, - "volume_uid": volume_uid, - "projection_type": "three_view", - }, + 'embryo_id': embryo_id, + 'timepoint': timepoint, + 'shape': list(volume.shape), + 'projection_uid': projection_uid, + 'volume_uid': volume_uid, + 'projection_type': 'three_view', + } ) except Exception as e: logger.warning(f"Failed to push to viz: {e}") - self._emit_event( - EventType.VOLUME_ACQUIRED, - { - "embryo_id": embryo_id, - "timepoint": timepoint, - "volume_uid": volume_uid, - "projection_uid": projection_uid, - "volume_path": str(stored_path) if stored_path else None, - "shape": list(volume.shape), - }, - ) + self._emit_event(EventType.VOLUME_ACQUIRED, { + 'embryo_id': embryo_id, + 'timepoint': timepoint, + 'volume_uid': volume_uid, + 'projection_uid': projection_uid, + 'volume_path': str(stored_path) if stored_path else None, + 'shape': list(volume.shape), + }) return { - "volume_uid": volume_uid, - "projection_uid": projection_uid, + 'volume_uid': volume_uid, + 'projection_uid': projection_uid, } def should_stop_experiment(self) -> bool: @@ -1551,31 +1485,22 @@ def should_stop_experiment(self) -> bool: return False return all(e.should_skip for e in self.experiment.embryos.values()) - def get_embryo_acquisition_order(self) -> list[str]: + def get_embryo_acquisition_order(self) -> List[str]: """Get embryo acquisition order based on priority.""" - high = [ - e.id - for e in self.experiment.embryos.values() - if e.priority == "high" and not e.should_skip - ] - normal = [ - e.id - for e in self.experiment.embryos.values() - if e.priority == "normal" and not e.should_skip - ] - low = [ - e.id - for e in self.experiment.embryos.values() - if e.priority == "low" and not e.should_skip - ] + high = [e.id for e in self.experiment.embryos.values() if e.priority == "high" and not e.should_skip] + normal = [e.id for e in self.experiment.embryos.values() if e.priority == "normal" and not e.should_skip] + low = [e.id for e in self.experiment.embryos.values() if e.priority == "low" and not e.should_skip] return high + normal + low - def decide_parameters(self, embryo_id: str, timepoint: int) -> dict: + def decide_parameters(self, embryo_id: str, timepoint: int) -> Dict: """Get current acquisition parameters for embryo.""" embryo = self.experiment.embryos.get(embryo_id) if not embryo: - return {"num_slices": 50, "exposure_ms": 10.0} - return {"num_slices": embryo.num_slices, "exposure_ms": embryo.exposure_ms} + return {'num_slices': 50, 'exposure_ms': 10.0} + return { + 'num_slices': embryo.num_slices, + 'exposure_ms': embryo.exposure_ms + } def decide_next_interval(self, timepoint: int) -> float: """Decide interval until next timepoint.""" @@ -1600,9 +1525,8 @@ async def check_blank_image( logger.warning(f"[BLANK_CHECK] {embryo_id}: Numerical check indicates blank image") return True - import base64 import io - + import base64 from PIL import Image if max_proj.max() > 0: @@ -1612,11 +1536,10 @@ async def check_blank_image( img = Image.fromarray(normalized) buffer = io.BytesIO() - img.save(buffer, format="PNG") + img.save(buffer, format='PNG') b64_image = base64.b64encode(buffer.getvalue()).decode() - prompt = """Look at this microscopy image. Is this a VALID microscopy image or a -BLANK/CORRUPTED image? + prompt = """Look at this microscopy image. Is this a VALID microscopy image or a BLANK/CORRUPTED image? A BLANK or CORRUPTED image shows: - Mostly uniform gray/black with no structure @@ -1634,22 +1557,20 @@ async def check_blank_image( self.claude.messages.create, model=settings.models.fast, max_tokens=10, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": b64_image, - }, - }, - ], - } - ], + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": b64_image + } + } + ] + }] ) result = response.content[0].text.strip().upper() @@ -1660,11 +1581,7 @@ async def check_blank_image( return is_blank - except ( - anthropic.APIConnectionError, - anthropic.RateLimitError, - anthropic.APIStatusError, - ) as e: + except (anthropic.APIConnectionError, anthropic.RateLimitError, anthropic.APIStatusError) as e: logger.error(f"[BLANK_CHECK] Claude API error for {embryo_id}: {e}") return False except Exception as e: diff --git a/gently/app/detectors/dopaminergic_signal.py b/gently/app/detectors/dopaminergic_signal.py index 7fb09408..9e9edbd9 100644 --- a/gently/app/detectors/dopaminergic_signal.py +++ b/gently/app/detectors/dopaminergic_signal.py @@ -273,7 +273,9 @@ async def _call_perceiver( } ], ) - raw = response.content[0].text if response.content else "" + if response.stop_reason == "refusal" or not response.content: + return "(perception model declined the request)", "" + raw = response.content[0].text return raw.strip(), raw async def _call_classifier( @@ -290,7 +292,9 @@ async def _call_classifier( max_tokens=300, messages=[{"role": "user", "content": prompt}], ) - raw = response.content[0].text if response.content else "" + if response.stop_reason == "refusal" or not response.content: + return dict(_DEFAULT_FINDINGS), "", "Safety refusal" + raw = response.content[0].text findings, parse_err = _parse_response(raw) return findings, raw, parse_err diff --git a/gently/hardware/dispim/sam_detection.py b/gently/hardware/dispim/sam_detection.py index cea9990a..7014338a 100644 --- a/gently/hardware/dispim/sam_detection.py +++ b/gently/hardware/dispim/sam_detection.py @@ -5,28 +5,29 @@ Returns embryo positions (pixel + stage coordinates) for calibration workflow. """ -import base64 -import json import logging -import os +import time +import json import uuid -from io import BytesIO +import numpy as np from pathlib import Path - -import anthropic import cv2 -import numpy as np +import base64 +from io import BytesIO from PIL import Image +import anthropic +from typing import Dict, List, Tuple, Optional +import os from gently.settings import settings logger = logging.getLogger(__name__) -from gently.core.coordinates import ( # noqa: E402 - DEFAULT_OBJECTIVE_MAG, - DEFAULT_PIXEL_SIZE_UM, - get_um_per_pixel, +from gently.core.coordinates import ( pixel_to_stage_position, + get_um_per_pixel, + DEFAULT_PIXEL_SIZE_UM, + DEFAULT_OBJECTIVE_MAG, ) @@ -41,13 +42,11 @@ class SAMEmbryoDetector: - Returns embryo positions as simple list of coordinates """ - def __init__( - self, - sam_checkpoint: str = "sam_vit_b_01ec64.pth", - sam_model_type: str = "vit_b", - device: str = "cpu", - anthropic_api_key: str | None = None, - ): + def __init__(self, + sam_checkpoint: str = "sam_vit_b_01ec64.pth", + sam_model_type: str = "vit_b", + device: str = "cpu", + anthropic_api_key: Optional[str] = None): """ Initialize SAM detector @@ -86,7 +85,7 @@ def _load_sam(self): if self._mask_generator is not None: return - from segment_anything import SamAutomaticMaskGenerator, SamPredictor, sam_model_registry + from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor if not Path(self.sam_checkpoint).exists(): raise FileNotFoundError(f"SAM checkpoint not found: {self.sam_checkpoint}") @@ -109,15 +108,13 @@ def _load_sam(self): self._predictor = SamPredictor(sam) logger.info("SAM model loaded") - def preprocess_image( - self, - image: np.ndarray, - bg_kernel_size: int = 150, - use_clahe: bool = True, - clahe_clip_limit: float = 3.0, - clahe_tile_size: int = 16, - gaussian_sigma: float = 2.0, - ) -> np.ndarray: + def preprocess_image(self, + image: np.ndarray, + bg_kernel_size: int = 150, + use_clahe: bool = True, + clahe_clip_limit: float = 3.0, + clahe_tile_size: int = 16, + gaussian_sigma: float = 2.0) -> np.ndarray: """ Preprocess image for better SAM detection. @@ -154,18 +151,14 @@ def preprocess_image( # This stretches the narrow range (e.g., 84-354) to full 0-255 logger.debug("Percentile normalization (2-98%%)...") p2, p98 = np.percentile(image, (2, 98)) - img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype( - np.uint8 - ) + img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype(np.uint8) logger.debug("Normalized to 0-255") # Step 2: Background subtraction with large morphological opening # Removes large-scale illumination variations if bg_kernel_size > 0: logger.debug("Background subtraction (kernel=%d)...", bg_kernel_size) - kernel_bg = cv2.getStructuringElement( - cv2.MORPH_ELLIPSE, (bg_kernel_size, bg_kernel_size) - ) + kernel_bg = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (bg_kernel_size, bg_kernel_size)) background = cv2.morphologyEx(img_norm, cv2.MORPH_OPEN, kernel_bg) img_no_bg = cv2.subtract(img_norm, background) img_no_bg = cv2.normalize(img_no_bg, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) @@ -178,7 +171,8 @@ def preprocess_image( if use_clahe: logger.debug("CLAHE (clip=%.1f, tile=%d)...", clahe_clip_limit, clahe_tile_size) clahe = cv2.createCLAHE( - clipLimit=clahe_clip_limit, tileGridSize=(clahe_tile_size, clahe_tile_size) + clipLimit=clahe_clip_limit, + tileGridSize=(clahe_tile_size, clahe_tile_size) ) img_enhanced = clahe.apply(img_no_bg) logger.debug("CLAHE applied") @@ -193,20 +187,16 @@ def preprocess_image( else: img_smooth = img_enhanced - logger.debug( - "Preprocessing complete (output range: %s - %s)", img_smooth.min(), img_smooth.max() - ) + logger.debug("Preprocessing complete (output range: %s - %s)", img_smooth.min(), img_smooth.max()) return img_smooth - def find_embryo_candidates( - self, - image: np.ndarray, - brightness_percentile: float = 99.0, - min_area: int = 5000, - max_area: int = 150000, - clahe_clip: float = 3.0, - clahe_tile: int = 16, - ) -> tuple[list[dict], np.ndarray]: + def find_embryo_candidates(self, + image: np.ndarray, + brightness_percentile: float = 99.0, + min_area: int = 5000, + max_area: int = 150000, + clahe_clip: float = 3.0, + clahe_tile: int = 16) -> Tuple[List[Dict], np.ndarray]: """ Find embryo candidates using brightness-based detection. @@ -239,16 +229,12 @@ def find_embryo_candidates( enhanced_image : np.ndarray Contrast-enhanced 8-bit image for SAM """ - logger.info( - "Finding embryo candidates (brightness percentile=%.1f)...", brightness_percentile - ) + logger.info("Finding embryo candidates (brightness percentile=%.1f)...", brightness_percentile) logger.debug("Input range: %s - %s", image.min(), image.max()) # Step 1: Percentile normalization (handles low dynamic range) p2, p98 = np.percentile(image, (2, 98)) - img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype( - np.uint8 - ) + img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype(np.uint8) logger.debug("Normalized to 0-255") # Step 2: CLAHE for local contrast enhancement @@ -275,9 +261,7 @@ def find_embryo_candidates( logger.debug("Morphological cleanup complete") # Step 7: Find connected components and filter by area - num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( - mask, connectivity=8 - ) + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) candidates = [] for i in range(1, num_labels): # Skip background (label 0) @@ -289,14 +273,19 @@ def find_embryo_candidates( h = stats[i, cv2.CC_STAT_HEIGHT] cx, cy = centroids[i] - candidates.append({"bbox": (x, y, w, h), "centroid": (cx, cy), "area": area}) + candidates.append({ + 'bbox': (x, y, w, h), + 'centroid': (cx, cy), + 'area': area + }) logger.info("Found %d embryo candidates", len(candidates)) return candidates, img_smooth - def refine_with_sam( - self, image: np.ndarray, candidates: list[dict], padding: int = 20 - ) -> list[dict]: + def refine_with_sam(self, + image: np.ndarray, + candidates: List[Dict], + padding: int = 20) -> List[Dict]: """ Refine embryo candidates using SAM with bounding box prompts. @@ -333,7 +322,7 @@ def refine_with_sam( h, w = image.shape[:2] for i, candidate in enumerate(candidates): - x, y, bw, bh = candidate["bbox"] + x, y, bw, bh = candidate['bbox'] # Add padding and clip to image bounds x1 = max(0, x - padding) @@ -346,7 +335,10 @@ def refine_with_sam( # Get SAM prediction with box prompt masks, scores, _ = self._predictor.predict( - point_coords=None, point_labels=None, box=input_box, multimask_output=True + point_coords=None, + point_labels=None, + box=input_box, + multimask_output=True ) # Take best mask (highest score) @@ -356,7 +348,9 @@ def refine_with_sam( # Calculate properties from SAM mask contours, _ = cv2.findContours( - mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + mask.astype(np.uint8), + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE ) if contours: @@ -370,47 +364,41 @@ def refine_with_sam( cx = M["m10"] / M["m00"] cy = M["m01"] / M["m00"] else: - cx, cy = candidate["centroid"] + cx, cy = candidate['centroid'] # Calculate circularity perimeter = cv2.arcLength(contour, True) - circularity = 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0 + circularity = 4 * np.pi * area / (perimeter ** 2) if perimeter > 0 else 0 # Get bounding box from contour bx, by, bw, bh = cv2.boundingRect(contour) - embryos.append( - { - "embryo_id": f"embryo_{i + 1}", - "uid": str( - uuid.uuid4() - ), # Global unique identifier for cross-session tracking - "pixel_x": float(cx), - "pixel_y": float(cy), - "bbox": (bx, by, bw, bh), # Used by visualization functions - "area_pixels": int(area), - "circularity": float(circularity), - "confidence": float(score), - "mask": mask, - } - ) + embryos.append({ + 'embryo_id': f'embryo_{i + 1}', + 'uid': str(uuid.uuid4()), # Global unique identifier for cross-session tracking + 'pixel_x': float(cx), + 'pixel_y': float(cy), + 'bbox': (bx, by, bw, bh), # Used by visualization functions + 'area_pixels': int(area), + 'circularity': float(circularity), + 'confidence': float(score), + 'mask': mask + }) logger.info("SAM refined %d embryos", len(embryos)) return embryos - async def detect_embryos( - self, - image: np.ndarray, - stage_position: tuple[float, float], - pixel_size_um: float = DEFAULT_PIXEL_SIZE_UM, - objective_mag: float = DEFAULT_OBJECTIVE_MAG, - use_claude_review: bool = True, - save_visualizations: bool = True, - output_dir: Path | None = None, - brightness_percentile: float = 99.0, - min_area: int = 5000, - max_area: int = 150000, - ) -> dict: + async def detect_embryos(self, + image: np.ndarray, + stage_position: Tuple[float, float], + pixel_size_um: float = DEFAULT_PIXEL_SIZE_UM, + objective_mag: float = DEFAULT_OBJECTIVE_MAG, + use_claude_review: bool = True, + save_visualizations: bool = True, + output_dir: Optional[Path] = None, + brightness_percentile: float = 99.0, + min_area: int = 5000, + max_area: int = 150000) -> Dict: """ Detect embryos using brightness-based detection + SAM refinement. @@ -474,17 +462,20 @@ async def detect_embryos( # Step 1: Find candidates using brightness detection logger.info("[1/4] Finding embryo candidates (brightness-based)...") candidates, image_enhanced = self.find_embryo_candidates( - image, brightness_percentile=brightness_percentile, min_area=min_area, max_area=max_area + image, + brightness_percentile=brightness_percentile, + min_area=min_area, + max_area=max_area ) if len(candidates) == 0: logger.warning("No embryo candidates found!") return { - "embryos": [], - "initial_detections": 0, - "final_detections": 0, - "verification": {"verified": False}, - "images": {}, + 'embryos': [], + 'initial_detections': 0, + 'final_detections': 0, + 'verification': {'verified': False}, + 'images': {} } # Step 2: Refine with SAM @@ -498,11 +489,11 @@ async def detect_embryos( if len(embryos_sam) == 0: logger.warning("No embryos detected by SAM!") return { - "embryos": [], - "initial_detections": 0, - "final_detections": 0, - "verification": {"verified": False}, - "images": {}, + 'embryos': [], + 'initial_detections': 0, + 'final_detections': 0, + 'verification': {'verified': False}, + 'images': {} } # Save initial detection @@ -512,8 +503,8 @@ async def detect_embryos( # Claude review (if enabled) embryos_final = embryos_sam - verification = {"verified": True, "skipped": not use_claude_review} - changes = {"round1": {"removed": [], "added": []}} + verification = {'verified': True, 'skipped': not use_claude_review} + changes = {'round1': {'removed': [], 'added': []}} if use_claude_review and self.claude_client: logger.info("[2/4] Claude Vision review (Round 1)...") @@ -521,7 +512,7 @@ async def detect_embryos( review_r1 = await self._review_with_claude(image_8bit, annotated, embryos_sam) logger.info("[3/4] Applying corrections...") - embryos_r1, changes["round1"] = self._apply_corrections( + embryos_r1, changes['round1'] = self._apply_corrections( embryos_sam, review_r1, image, self._predictor ) @@ -533,22 +524,22 @@ async def detect_embryos( logger.info("[4/4] Claude verification (Round 2)...") r1_viz = self._create_annotated_image(image_8bit, embryos_r1) verification = await self._verify_with_claude( - image_8bit, r1_viz, embryos_r1, changes["round1"] + image_8bit, r1_viz, embryos_r1, changes['round1'] ) # Apply round 2 corrections if needed has_r2_changes = ( - len(verification.get("additional_false_positives", [])) > 0 - or len(verification.get("additional_false_negatives", [])) > 0 + len(verification.get('additional_false_positives', [])) > 0 or + len(verification.get('additional_false_negatives', [])) > 0 ) if has_r2_changes: logger.info("Applying Round 2 corrections...") review_r2 = { - "false_positives": verification.get("additional_false_positives", []), - "false_negatives": verification.get("additional_false_negatives", []), + 'false_positives': verification.get('additional_false_positives', []), + 'false_negatives': verification.get('additional_false_negatives', []) } - embryos_final, changes["round2"] = self._apply_corrections( + embryos_final, changes['round2'] = self._apply_corrections( embryos_r1, review_r2, image, self._predictor ) else: @@ -562,7 +553,7 @@ async def detect_embryos( stage_position, pixel_size_um, objective_mag, - image_shape=image.shape[:2], # (height, width) + image_shape=image.shape[:2] # (height, width) ) # Save final visualization @@ -572,19 +563,19 @@ async def detect_embryos( # Package results results = { - "embryos": embryo_positions, - "initial_detections": len(embryos_sam), - "final_detections": len(embryos_final), - "verification": verification, - "changes": changes, - "images": { - "initial": str(output_dir / "detection_initial.png"), - "final": str(output_dir / "detection_final.png"), - }, + 'embryos': embryo_positions, + 'initial_detections': len(embryos_sam), + 'final_detections': len(embryos_final), + 'verification': verification, + 'changes': changes, + 'images': { + 'initial': str(output_dir / "detection_initial.png"), + 'final': str(output_dir / "detection_final.png") + } } if use_claude_review and save_visualizations: - results["images"]["round1"] = str(output_dir / "detection_round1.png") + results['images']['round1'] = str(output_dir / "detection_round1.png") logger.info("=" * 70) logger.info("DETECTION COMPLETE: %d embryos", len(embryo_positions)) @@ -603,7 +594,7 @@ def _to_rgb8(image: np.ndarray) -> np.ndarray: return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) return image - def _detect_with_sam(self, image: np.ndarray) -> tuple[list[dict], np.ndarray]: + def _detect_with_sam(self, image: np.ndarray) -> Tuple[List[Dict], np.ndarray]: """Run SAM automatic segmentation (extracted from test script)""" image_rgb = self._to_rgb8(image) @@ -613,16 +604,14 @@ def _detect_with_sam(self, image: np.ndarray) -> tuple[list[dict], np.ndarray]: # Filter candidates embryo_candidates = [] for mask_data in masks: - area = mask_data["area"] + area = mask_data['area'] if not (self.min_area <= area <= self.max_area): continue - bbox = mask_data["bbox"] - mask = mask_data["segmentation"] - contours, _ = cv2.findContours( - mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE - ) + bbox = mask_data['bbox'] + mask = mask_data['segmentation'] + contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) == 0: continue @@ -631,44 +620,40 @@ def _detect_with_sam(self, image: np.ndarray) -> tuple[list[dict], np.ndarray]: if perimeter == 0: continue - circularity = 4 * np.pi * area / (perimeter**2) + circularity = 4 * np.pi * area / (perimeter ** 2) if circularity < self.min_circularity: continue - embryo_candidates.append( - { - "mask": mask, - "bbox": bbox, - "area": area, - "circularity": circularity, - "stability_score": mask_data["stability_score"], - "predicted_iou": mask_data["predicted_iou"], - } - ) + embryo_candidates.append({ + 'mask': mask, + 'bbox': bbox, + 'area': area, + 'circularity': circularity, + 'stability_score': mask_data['stability_score'], + 'predicted_iou': mask_data['predicted_iou'] + }) # Sort by quality and apply spatial separation - embryo_candidates.sort(key=lambda x: x["area"] * x["stability_score"], reverse=True) + embryo_candidates.sort(key=lambda x: (x['area'] * x['stability_score']), reverse=True) selected_embryos = [] for candidate in embryo_candidates: if len(selected_embryos) >= self.max_embryos: break - bbox = candidate["bbox"] + bbox = candidate['bbox'] candidate_center_x = bbox[0] + bbox[2] / 2 candidate_center_y = bbox[1] + bbox[3] / 2 too_close = False for selected in selected_embryos: - sel_bbox = selected["bbox"] + sel_bbox = selected['bbox'] sel_center_x = sel_bbox[0] + sel_bbox[2] / 2 sel_center_y = sel_bbox[1] + sel_bbox[3] / 2 - distance = np.sqrt( - (candidate_center_x - sel_center_x) ** 2 - + (candidate_center_y - sel_center_y) ** 2 - ) + distance = np.sqrt((candidate_center_x - sel_center_x)**2 + + (candidate_center_y - sel_center_y)**2) if distance < self.min_separation_pixels: too_close = True @@ -677,27 +662,19 @@ def _detect_with_sam(self, image: np.ndarray) -> tuple[list[dict], np.ndarray]: if not too_close: selected_embryos.append(candidate) - return selected_embryos, image_rgb + return selected_embryos, image_8bit - def _create_annotated_image(self, image: np.ndarray, embryos: list[dict]) -> np.ndarray: + def _create_annotated_image(self, image: np.ndarray, embryos: List[Dict]) -> np.ndarray: """Create annotated image with numbered boxes""" viz = image.copy() if len(viz.shape) == 2: viz = cv2.cvtColor(viz, cv2.COLOR_GRAY2RGB) - colors = [ - (255, 0, 0), - (0, 255, 0), - (0, 0, 255), - (255, 255, 0), - (255, 0, 255), - (0, 255, 255), - (128, 128, 0), - (128, 0, 128), - ] + colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), + (255, 0, 255), (0, 255, 255), (128, 128, 0), (128, 0, 128)] for i, embryo in enumerate(embryos): - bbox = embryo["bbox"] + bbox = embryo['bbox'] x, y, w, h = bbox color = colors[i % len(colors)] @@ -736,7 +713,7 @@ def _encode_image_base64(self, image: np.ndarray) -> str: buffered = BytesIO() pil_image.save(buffered, format="JPEG", quality=quality, optimize=True) if buffered.tell() <= max_bytes: - return base64.b64encode(buffered.getvalue()).decode("utf-8") + return base64.b64encode(buffered.getvalue()).decode('utf-8') quality -= 5 # Last resort @@ -745,21 +722,18 @@ def _encode_image_base64(self, image: np.ndarray) -> str: pil_image = pil_image.resize(new_size, Image.Resampling.LANCZOS) buffered = BytesIO() pil_image.save(buffered, format="JPEG", quality=85, optimize=True) - return base64.b64encode(buffered.getvalue()).decode("utf-8") + return base64.b64encode(buffered.getvalue()).decode('utf-8') - async def _review_with_claude( - self, image: np.ndarray, annotated: np.ndarray, embryos: list[dict] - ) -> dict: + async def _review_with_claude(self, image: np.ndarray, annotated: np.ndarray, embryos: List[Dict]) -> Dict: """Round 1: Claude reviews detections (from test script)""" if not self.claude_client: - return {"false_positives": [], "false_negatives": []} + return {'false_positives': [], 'false_negatives': []} image_base64 = self._encode_image_base64(annotated) - prompt = f"""You are a microscopy expert analyzing embryo detections from a bottom -camera view. + prompt = f"""You are a microscopy expert analyzing embryo detections from a bottom camera view. -CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos) - 1} with colored bounding boxes. +CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos)-1} with colored bounding boxes. EMBRYO CHARACTERISTICS: - Small, BRIGHT white/light gray oval or rice grain shapes @@ -789,23 +763,14 @@ async def _review_with_claude( message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=8000, - thinking={"type": "enabled", "budget_tokens": 5000}, - messages=[ - { - "role": "user", - "content": [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": image_base64, - }, - }, - {"type": "text", "text": prompt}, - ], - } - ], + output_config={"effort": "high"}, # was thinking budget_tokens (Opus 4.8 rejects it) + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_base64}}, + {"type": "text", "text": prompt} + ] + }] ) response_text = next((b.text for b in message.content if b.type == "text"), "") @@ -822,19 +787,18 @@ async def _review_with_claude( except Exception as e: logger.warning("Claude review failed: %s", e) - return {"false_positives": [], "false_negatives": []} + return {'false_positives': [], 'false_negatives': []} - async def _verify_with_claude( - self, image: np.ndarray, annotated: np.ndarray, embryos: list[dict], previous_changes: dict - ) -> dict: + async def _verify_with_claude(self, image: np.ndarray, annotated: np.ndarray, + embryos: List[Dict], previous_changes: Dict) -> Dict: """Round 2: Claude verifies corrections (from test script)""" if not self.claude_client: - return {"verified": True, "skipped": True} + return {'verified': True, 'skipped': True} image_base64 = self._encode_image_base64(annotated) - removed = previous_changes.get("removed", []) - added = previous_changes.get("added", []) + removed = previous_changes.get('removed', []) + added = previous_changes.get('added', []) prompt = f"""VERIFICATION ROUND - You previously reviewed this image. @@ -842,7 +806,7 @@ async def _verify_with_claude( - Removed: {removed if removed else "none"} - Added: {added if added else "none"} -CURRENT: {len(embryos)} detections (numbered 0-{len(embryos) - 1}) +CURRENT: {len(embryos)} detections (numbered 0-{len(embryos)-1}) TASK: Verify corrections and catch any remaining issues. Only report CLEAR remaining problems. @@ -859,23 +823,14 @@ async def _verify_with_claude( message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=6000, - thinking={"type": "enabled", "budget_tokens": 4000}, - messages=[ - { - "role": "user", - "content": [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": image_base64, - }, - }, - {"type": "text", "text": prompt}, - ], - } - ], + output_config={"effort": "high"}, # was thinking budget_tokens (Opus 4.8 rejects it) + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_base64}}, + {"type": "text", "text": prompt} + ] + }] ) response_text = next((b.text for b in message.content if b.type == "text"), "") @@ -891,41 +846,38 @@ async def _verify_with_claude( except Exception as e: logger.warning("Verification failed: %s", e) - return {"verified": False} + return {'verified': False} - def _apply_corrections( - self, embryos: list[dict], review: dict, image: np.ndarray, predictor - ) -> tuple[list[dict], dict]: + def _apply_corrections(self, embryos: List[Dict], review: Dict, + image: np.ndarray, predictor) -> Tuple[List[Dict], Dict]: """Apply Claude's corrections (from test script)""" corrected = [] - changes = {"removed": [], "added": []} + changes = {'removed': [], 'added': []} # Remove false positives - false_positives = set(review.get("false_positives", [])) + false_positives = set(review.get('false_positives', [])) if false_positives: - changes["removed"] = list(false_positives) + changes['removed'] = list(false_positives) for i, embryo in enumerate(embryos): if i not in false_positives: corrected.append(embryo) # Add false negatives - false_negatives = review.get("false_negatives", []) + false_negatives = review.get('false_negatives', []) if false_negatives: for fn in false_negatives: - point = (fn["x"], fn["y"]) + point = (fn['x'], fn['y']) new_embryo = self._segment_with_sam(image, predictor, point) - if new_embryo and ( - self.min_area <= new_embryo["area"] <= self.max_area - and new_embryo["circularity"] >= self.min_circularity - ): + if new_embryo and (self.min_area <= new_embryo['area'] <= self.max_area and + new_embryo['circularity'] >= self.min_circularity): corrected.append(new_embryo) - changes["added"].append(point) + changes['added'].append(point) return corrected, changes - def _segment_with_sam(self, image: np.ndarray, predictor, point: tuple) -> dict | None: + def _segment_with_sam(self, image: np.ndarray, predictor, point: Tuple) -> Optional[Dict]: """Use SAM predictor to segment region (from test script)""" image_rgb = self._to_rgb8(image) predictor.set_image(image_rgb) @@ -934,7 +886,9 @@ def _segment_with_sam(self, image: np.ndarray, predictor, point: tuple) -> dict point_labels = np.array([1]) masks, scores, _ = predictor.predict( - point_coords=point_coords, point_labels=point_labels, multimask_output=True + point_coords=point_coords, + point_labels=point_labels, + multimask_output=True ) best_idx = np.argmax(scores) @@ -949,33 +903,26 @@ def _segment_with_sam(self, image: np.ndarray, predictor, point: tuple) -> dict bbox = [x_min, y_min, x_max - x_min, y_max - y_min] area = mask.sum() - contours, _ = cv2.findContours( - mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE - ) + contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) > 0: perimeter = cv2.arcLength(contours[0], True) - circularity = 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0 + circularity = 4 * np.pi * area / (perimeter ** 2) if perimeter > 0 else 0 else: circularity = 0 return { - "mask": mask, - "bbox": bbox, - "area": int(area), - "circularity": float(circularity), - "stability_score": float(scores[best_idx]), - "predicted_iou": float(scores[best_idx]), + 'mask': mask, + 'bbox': bbox, + 'area': int(area), + 'circularity': float(circularity), + 'stability_score': float(scores[best_idx]), + 'predicted_iou': float(scores[best_idx]) } - def _pixel_to_stage_coordinates( - self, - embryos: list[dict], - stage_pos: tuple[float, float], - pixel_size_um: float, - objective_mag: float, - image_shape: tuple[int, int] = (2048, 2048), - ) -> list[dict]: + def _pixel_to_stage_coordinates(self, embryos: List[Dict], stage_pos: Tuple[float, float], + pixel_size_um: float, objective_mag: float, + image_shape: Tuple[int, int] = (2048, 2048)) -> List[Dict]: """ Convert pixel coordinates to stage coordinates. @@ -991,7 +938,7 @@ def _pixel_to_stage_coordinates( embryo_positions = [] for i, embryo in enumerate(embryos): - bbox = embryo["bbox"] + bbox = embryo['bbox'] x, y, w, h = bbox center_x_px = x + w / 2 @@ -1006,26 +953,24 @@ def _pixel_to_stage_coordinates( image_center_y=image_center_y, stage_x=stage_x, stage_y=stage_y, - um_per_pixel=effective_pixel_um, + um_per_pixel=effective_pixel_um ) - embryo_positions.append( - { - "embryo_id": f"embryo_{i + 1}", - "pixel_x": float(center_x_px), - "pixel_y": float(center_y_px), - "stage_x_um": float(embryo_stage_x), - "stage_y_um": float(embryo_stage_y), - "bbox_pixel": tuple(bbox), - "area_pixels": embryo.get("area_pixels", embryo.get("area", 0)), - "circularity": embryo.get("circularity", 0), - "confidence": embryo.get("confidence", embryo.get("stability_score", 0)), - } - ) + embryo_positions.append({ + 'embryo_id': f'embryo_{i + 1}', + 'pixel_x': float(center_x_px), + 'pixel_y': float(center_y_px), + 'stage_x_um': float(embryo_stage_x), + 'stage_y_um': float(embryo_stage_y), + 'bbox_pixel': tuple(bbox), + 'area_pixels': embryo.get('area_pixels', embryo.get('area', 0)), + 'circularity': embryo.get('circularity', 0), + 'confidence': embryo.get('confidence', embryo.get('stability_score', 0)) + }) return embryo_positions - def show_in_napari(self, image: np.ndarray, embryos: list[dict], block: bool = False): + def show_in_napari(self, image: np.ndarray, embryos: List[Dict], block: bool = False): """Deprecated: napari display was retired in Phase 1. SAM detection results are now reviewed via the web map view — diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index e154c724..29573e6a 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -10,12 +10,14 @@ import logging import re import time -from typing import Any +from typing import Dict, List, Optional, Any + +from ..settings import settings logger = logging.getLogger(__name__) -def _extend_tool_calls(out: list[dict[str, Any]], content_blocks) -> None: +def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: """Append every tool_use block in content_blocks to out. Tolerates absent attributes (some SDK versions / mock objects) so it @@ -27,13 +29,11 @@ def _extend_tool_calls(out: list[dict[str, Any]], content_blocks) -> None: try: if getattr(block, "type", None) != "tool_use": continue - out.append( - { - "name": getattr(block, "name", None), - "input": getattr(block, "input", None), - "id": getattr(block, "id", None), - } - ) + out.append({ + "name": getattr(block, "name", None), + "input": getattr(block, "input", None), + "id": getattr(block, "id", None), + }) except Exception: continue @@ -55,7 +55,7 @@ def __init__(self, client, model, tool_registry): self._tool_registry = tool_registry # Conversation state - self.conversation_history: list[dict] = [] + self.conversation_history: List[Dict] = [] # Token counters self.total_input_tokens: int = 0 @@ -76,9 +76,8 @@ def __init__(self, client, model, tool_registry): # ===== Quick Response ===== - def try_quick_response( - self, message: str, experiment, mode: str, enter_plan_fn, exit_plan_fn - ) -> str | None: + def try_quick_response(self, message: str, experiment, mode: str, + enter_plan_fn, exit_plan_fn) -> Optional[str]: """ Answer simple queries from state without LLM call. @@ -107,13 +106,7 @@ def try_quick_response( return experiment.get_summary() # Plan mode switching via natural language - plan_enter_phrases = ( - "plan mode", - "enter plan", - "switch to plan", - "let's plan", - "design an experiment", - ) + plan_enter_phrases = ("plan mode", "enter plan", "switch to plan", "let's plan", "design an experiment") plan_exit_phrases = ("exit plan", "leave plan", "back to run", "run mode") if mode != "plan" and any(p in message_lower for p in plan_enter_phrases): @@ -149,36 +142,54 @@ def should_use_thinking(self, message: str, mode: str) -> bool: if mode == "plan": return True + import re msg_lower = message.lower() - if re.search(r"\bthink(ing)?\b", message, re.IGNORECASE): + if re.search(r'\bthink(ing)?\b', message, re.IGNORECASE): return True - if re.search(r"\bcalibrat", msg_lower): + if re.search(r'\bcalibrat', msg_lower): return True - if re.search(r"\b(plan|timelapse|time-lapse|acquisition)\b", msg_lower): + if re.search(r'\b(plan|timelapse|time-lapse|acquisition)\b', msg_lower): return True - if re.search( - r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)", - msg_lower, - ): + if re.search(r'\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)', msg_lower): return True - if re.search(r"\b(all|every|each)\s+(embryo|sample)", msg_lower): + if re.search(r'\b(all|every|each)\s+(embryo|sample)', msg_lower): return True - if re.search( - r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b", - msg_lower, - ): + if re.search(r'\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b', msg_lower): return True - if re.search(r"\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)", msg_lower): + if re.search(r'\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)', msg_lower): return True return False # ===== Non-Streaming API Call ===== - async def call_claude( - self, user_message: str, system_prompt, tools, mode: str, auto_save_fn - ) -> str: + async def _create_with_refusal_fallback(self, api_kwargs): + """messages.create with main-tier resilience: if the model rejects the + request with a 400 (e.g. Fable 5 under <30-day org data retention, or + unavailable) OR declines it (stop_reason="refusal", empty content), retry + the SAME request once on the fallback model (Opus 4.8) — so gently keeps + working whether or not Fable 5 is currently serviceable. The moment the + org retention is fixed, Fable 5 serves with no code change.""" + from anthropic import BadRequestError + fb = settings.models.refusal_fallback + model = api_kwargs.get("model") + try: + response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) + except BadRequestError: + if not fb or fb == model: + raise + logger.warning("Model %s rejected the request (400); falling back to %s", model, fb) + return await self._call_api_with_retry(self.claude.messages.create, **{**api_kwargs, "model": fb}) + if response.stop_reason == "refusal" and fb and fb != model: + logger.warning("Model %s declined the turn; retrying on %s", model, fb) + response = await self._call_api_with_retry( + self.claude.messages.create, **{**api_kwargs, "model": fb} + ) + return response + + async def call_claude(self, user_message: str, system_prompt, tools, + mode: str, auto_save_fn) -> str: """ Call Claude API with full context and tool access (non-streaming). @@ -210,8 +221,8 @@ async def call_claude( interaction = self.interaction_logger.start_interaction( user_prompt=user_message, system_state={ - "acquisition_status": "unknown", - }, + 'acquisition_status': 'unknown', + } ) # Snapshot inputs for decision capture BEFORE the tool loop starts @@ -222,15 +233,13 @@ async def call_claude( if self.decision_log is not None: try: from gently.eval import prompt_hash as _prompt_hash - decision_prompt_hash = _prompt_hash( - system_prompt, - list(self.conversation_history), + system_prompt, list(self.conversation_history), ) except Exception: logger.exception("Failed to compute decision prompt_hash") - tool_calls_collected: list[dict[str, Any]] = [] + tool_calls_collected: List[Dict[str, Any]] = [] assistant_message = "" error_occurred = None @@ -243,38 +252,51 @@ async def call_claude( "max_tokens": 16000 if use_thinking else 4096, } if use_thinking: - budget = 30000 if mode == "plan" else 10000 - api_kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget} + # Fable 5 / Opus 4.8 reject thinking budget_tokens (400) — thinking + # is adaptive; control depth via effort instead of a token budget. + api_kwargs["output_config"] = {"effort": "high" if mode == "plan" else "medium"} - response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) + response = await self._create_with_refusal_fallback(api_kwargs) self._track_token_usage(response) _extend_tool_calls(tool_calls_collected, response.content) # Process tool calls while response.stop_reason == "tool_use": - tool_results = await self._execute_tools_with_logging(response.content, interaction) + tool_results = await self._execute_tools_with_logging( + response.content, interaction + ) - self.conversation_history.append({"role": "assistant", "content": response.content}) - self.conversation_history.append({"role": "user", "content": tool_results}) + self.conversation_history.append({ + "role": "assistant", + "content": response.content + }) + self.conversation_history.append({ + "role": "user", + "content": tool_results + }) api_kwargs["messages"] = self.conversation_history - response = await self._call_api_with_retry( - self.claude.messages.create, **api_kwargs - ) + response = await self._create_with_refusal_fallback(api_kwargs) self._track_token_usage(response) _extend_tool_calls(tool_calls_collected, response.content) - # Extract text response - assistant_message = "" - for block in response.content: - if hasattr(block, "text"): - assistant_message += block.text + # Extract text response. Fable 5 may refuse (stop_reason="refusal") + # with empty content — surface it instead of returning blank. + if response.stop_reason == "refusal": + assistant_message = "(The request was declined by the model's safety system. Try rephrasing.)" + else: + assistant_message = "" + for block in response.content: + if hasattr(block, 'text'): + assistant_message += block.text - self.conversation_history.append({"role": "assistant", "content": response.content}) + self.conversation_history.append({ + "role": "assistant", + "content": response.content + }) except Exception as e: import traceback - error_occurred = str(e) error_tb = traceback.format_exc() assistant_message = f"Error: {error_occurred}" @@ -321,11 +343,11 @@ def _write_production_decision( self, *, user_message: str, - tool_calls: list[dict[str, Any]], + tool_calls: List[Dict[str, Any]], response_text: str, duration_ms: float, - prompt_hash_value: str | None, - error: str | None, + prompt_hash_value: Optional[str], + error: Optional[str], ) -> None: """Persist one production Decision row (best-effort). @@ -337,28 +359,24 @@ def _write_production_decision( return try: from datetime import datetime - from gently.eval import Decision, DecisionTrigger - - self.decision_log.append( - Decision( - timestamp=datetime.now(), - agent="production", - trigger=DecisionTrigger.USER_MESSAGE, - trigger_detail=(user_message or "")[:200], - tool_calls=tool_calls, - response_text=response_text, - prompt_hash=prompt_hash_value, - duration_ms=duration_ms, - error=error, - ) - ) + self.decision_log.append(Decision( + timestamp=datetime.now(), + agent="production", + trigger=DecisionTrigger.USER_MESSAGE, + trigger_detail=(user_message or "")[:200], + tool_calls=tool_calls, + response_text=response_text, + prompt_hash=prompt_hash_value, + duration_ms=duration_ms, + error=error, + )) except Exception: logger.exception("Failed to write production Decision") # ===== Dry-Run Tool Call (Benchmarking) ===== - async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | None: + async def get_tool_call(self, user_message: str, system_prompt, tools) -> Optional[Dict]: """ Get what tool Claude would call without executing it (dry-run mode). @@ -381,7 +399,10 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | start_time = time.time() messages = self.conversation_history.copy() - messages.append({"role": "user", "content": user_message}) + messages.append({ + "role": "user", + "content": user_message + }) try: api_kwargs = { @@ -392,13 +413,19 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | "max_tokens": 4096, } - response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) + response = await self._call_api_with_retry( + self.claude.messages.create, + **api_kwargs + ) latency_ms = (time.time() - start_time) * 1000 - input_tokens = getattr(response.usage, "input_tokens", 0) - output_tokens = getattr(response.usage, "output_tokens", 0) + input_tokens = getattr(response.usage, 'input_tokens', 0) + output_tokens = getattr(response.usage, 'output_tokens', 0) + # A refusal returns empty content — treat as "no tool call". + if response.stop_reason == "refusal" or not response.content: + return None for block in response.content: if block.type == "tool_use": return { @@ -417,7 +444,7 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | # ===== Tool Execution ===== - async def _execute_tools_with_logging(self, content_blocks, interaction) -> list[dict]: + async def _execute_tools_with_logging(self, content_blocks, interaction) -> List[Dict]: """ Execute Claude's tool calls with interaction logging. @@ -449,10 +476,7 @@ async def _execute_tools_with_logging(self, content_blocks, interaction) -> list if self.choice_handler and isinstance(result, str): try: choice_data = json.loads(result) - if ( - isinstance(choice_data, dict) - and choice_data.get("_type") == CHOICE_RESPONSE_TYPE - ): + if isinstance(choice_data, dict) and choice_data.get("_type") == CHOICE_RESPONSE_TYPE: user_selection = await self.choice_handler(choice_data) result = user_selection except (json.JSONDecodeError, TypeError): @@ -476,20 +500,19 @@ async def _execute_tools_with_logging(self, content_blocks, interaction) -> list error_message=error_message, ) - results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": result, - "is_error": is_error, - } - ) + results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + "is_error": is_error, + }) return results # ===== Streaming API Call ===== - async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_save_fn): + async def call_claude_stream(self, system_prompt, tools, + tool_label_fn, auto_save_fn): """ Call Claude API with streaming enabled. @@ -509,24 +532,31 @@ async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_sav dict Chunks as they arrive from Claude """ - from anthropic import APIStatusError - - def stream_and_collect(): - events = [] - final_message = None - - with self.claude.messages.stream( - model=self.model, - system=system_prompt, - messages=self.conversation_history, - tools=tools, - max_tokens=4096, - ) as stream: - for event in stream: - events.append(event) - final_message = stream.get_final_message() - - return events, final_message + from anthropic import APIStatusError, BadRequestError + + def stream_and_collect(model): + def _run(m): + events = [] + with self.claude.messages.stream( + model=m, + system=system_prompt, + messages=self.conversation_history, + tools=tools, + max_tokens=4096 + ) as stream: + for event in stream: + events.append(event) + return events, stream.get_final_message() + try: + return _run(model) + except BadRequestError: + # Fable 5 under <30-day org data retention (or unavailable) rejects + # with a 400 — fall back to Opus 4.8 so the turn still streams. + fb = settings.models.refusal_fallback + if not fb or fb == model: + raise + logger.warning("Stream model %s rejected the request (400); falling back to %s", model, fb) + return _run(fb) # Run streaming in thread with retry logic max_retries = 3 @@ -534,61 +564,66 @@ def stream_and_collect(): for attempt in range(max_retries): try: - events, final_message = await asyncio.to_thread(stream_and_collect) + events, final_message = await asyncio.to_thread(stream_and_collect, self.model) self._track_token_usage(final_message) break except APIStatusError as e: - error_type = getattr(e, "body", {}) + error_type = getattr(e, 'body', {}) if isinstance(error_type, dict): - error_type = error_type.get("error", {}).get("type", "") + error_type = error_type.get('error', {}).get('type', '') - if ( - error_type in ("overloaded_error", "rate_limit_error") - or "overloaded" in str(e).lower() - ): + if error_type in ('overloaded_error', 'rate_limit_error') or 'overloaded' in str(e).lower(): if attempt < max_retries - 1: - wait_time = retry_delay * (2**attempt) - logger.warning( - f"API overloaded, retrying in {wait_time:.1f}s" - f" (attempt {attempt + 1}/{max_retries})" - ) - yield { - "type": "text", - "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", - } + wait_time = retry_delay * (2 ** attempt) + logger.warning(f"API overloaded, retrying in {wait_time:.1f}s (attempt {attempt + 1}/{max_retries})") + yield {'type': 'text', 'text': f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n"} await asyncio.sleep(wait_time) continue raise else: raise RuntimeError("API overloaded after multiple retries") - # Diagnostic: log stop_reason and tool block counts + # Refusal → retry the whole streamed turn on the fallback model (Opus 4.8) + # before giving up. The original partial output is discarded (we re-collect + # and only yield the fallback's events below). + fb = settings.models.refusal_fallback + if final_message.stop_reason == "refusal" and fb and fb != self.model: + logger.warning("Model %s declined the streamed turn; retrying on %s", self.model, fb) + events, final_message = await asyncio.to_thread(stream_and_collect, fb) + self._track_token_usage(final_message) + + # Last resort: if even the fallback declined, surface it and stop — + # discard any partial, don't iterate empty content or process tools. + if final_message.stop_reason == "refusal": + logger.warning("Claude declined the request (model=%s)", self.model) + yield {'type': 'text', 'text': "(The request was declined by the model's safety system. Try rephrasing.)"} + return + + # Diagnostic: per-response counts. DEBUG, not WARNING — stop_reason=tool_use + # with matching tool blocks is normal; the genuine anomaly is the + # logger.error below (tool blocks present but stop_reason != tool_use). tool_block_count = sum( - 1 for b in final_message.content if hasattr(b, "type") and b.type == "tool_use" + 1 for b in final_message.content + if hasattr(b, 'type') and b.type == 'tool_use' ) - logger.warning( - "Claude response: stop_reason=%s, content_blocks=%d, tool_use_blocks=%d," - " tools_passed=%d, model=%s", - final_message.stop_reason, - len(final_message.content), - tool_block_count, - len(tools), - self.model, + logger.debug( + "Claude response: stop_reason=%s, content_blocks=%d, tool_use_blocks=%d, tools_passed=%d, model=%s", + final_message.stop_reason, len(final_message.content), + tool_block_count, len(tools), self.model, ) if tool_block_count > 0 and final_message.stop_reason != "tool_use": logger.error( "BUG: Claude returned %d tool_use blocks but stop_reason=%s (expected 'tool_use')", - tool_block_count, - final_message.stop_reason, + tool_block_count, final_message.stop_reason, ) # Process events and yield text full_text = [] for event in events: if event.type == "content_block_delta": - if hasattr(event.delta, "text"): + if hasattr(event.delta, 'text'): full_text.append(event.delta.text) - yield {"type": "text", "text": event.delta.text} + yield {'type': 'text', 'text': event.delta.text} # Detect fake XML tool calls in text (Claude writing tool_use as text) joined_text = "".join(full_text) @@ -596,8 +631,7 @@ def stream_and_collect(): logger.error( "DETECTED: Claude wrote XML tool tags as plain text instead of " "using API tool_use mechanism. stop_reason=%s, text_preview=%.200s", - final_message.stop_reason, - joined_text[:200], + final_message.stop_reason, joined_text[:200], ) response_content = final_message.content @@ -611,14 +645,14 @@ def stream_and_collect(): tool_results = [] for block in response_content: - if hasattr(block, "type") and block.type == "tool_use": + if hasattr(block, 'type') and block.type == "tool_use": start_time = time.time() yield { - "type": "tool_start", - "tool_name": block.name, - "tool_input": block.input, - "tool_label": tool_label_fn(block.name, block.input), + 'type': 'tool_start', + 'tool_name': block.name, + 'tool_input': block.input, + 'tool_label': tool_label_fn(block.name, block.input), } is_error_flag = False @@ -628,44 +662,32 @@ def stream_and_collect(): if isinstance(tool_result, str): try: - from gently.app.tools.interaction_tools import ( - CHOICE_RESPONSE_TYPE, - ) - + from gently.app.tools.interaction_tools import CHOICE_RESPONSE_TYPE choice_data = json.loads(tool_result) - if ( - isinstance(choice_data, dict) - and choice_data.get("_type") == CHOICE_RESPONSE_TYPE - ): + if isinstance(choice_data, dict) and choice_data.get("_type") == CHOICE_RESPONSE_TYPE: user_selection = yield { - "type": "choice_request", - "choice_data": choice_data, + 'type': 'choice_request', + 'choice_data': choice_data } tool_result = user_selection or "cancelled" except (json.JSONDecodeError, TypeError): pass - result_text = ( - tool_result if isinstance(tool_result, str) else str(tool_result) - ) - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": tool_result, - } - ) + result_text = tool_result if isinstance(tool_result, str) else str(tool_result) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": tool_result + }) except Exception as e: is_error_flag = True result_text = f"Error: {str(e)}" - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": result_text, - "is_error": True, - } - ) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result_text, + "is_error": True + }) # First non-empty line of the result, trimmed — gives the chat # UI a one-line summary so the operator can see what a tool did @@ -678,16 +700,22 @@ def stream_and_collect(): result_summary = result_summary[:139] + "…" yield { - "type": "tool_call", - "tool_name": block.name, - "tool_input": block.input, - "duration": time.time() - start_time, - "result_summary": result_summary, - "is_error": is_error_flag, + 'type': 'tool_call', + 'tool_name': block.name, + 'tool_input': block.input, + 'duration': time.time() - start_time, + 'result_summary': result_summary, + 'is_error': is_error_flag, } - self.conversation_history.append({"role": "assistant", "content": response_content}) - self.conversation_history.append({"role": "user", "content": tool_results}) + self.conversation_history.append({ + "role": "assistant", + "content": response_content + }) + self.conversation_history.append({ + "role": "user", + "content": tool_results + }) auto_save_fn() @@ -709,12 +737,15 @@ def stream_and_collect(): else: # No tool calls - add final message to history - self.conversation_history.append({"role": "assistant", "content": response_content}) + self.conversation_history.append({ + "role": "assistant", + "content": response_content + }) auto_save_fn() # ===== Tool Label ===== - def tool_label(self, tool_name: str, tool_input: dict) -> str: + def tool_label(self, tool_name: str, tool_input: Dict) -> str: """Build a human-readable label for a tool call. Used in tool_start chunks so the TUI shows biologist-friendly @@ -728,13 +759,8 @@ def tool_label(self, tool_name: str, tool_input: dict) -> str: campaign = self.context_store.get_campaign(campaign_id) if campaign: campaign_label = campaign.shorthand or campaign.description - if tool_name in ( - "propose_plan", - "get_plan_status", - "export_plan", - "snapshot_plan", - "list_plan_versions", - ): + if tool_name in ("propose_plan", "get_plan_status", "export_plan", + "snapshot_plan", "list_plan_versions"): return campaign_label if tool_name == "create_campaign" and inp.get("parent_id"): return f"phase under {campaign_label}" @@ -752,12 +778,8 @@ def tool_label(self, tool_name: str, tool_input: dict) -> str: # Item reference tools item_ref = inp.get("item_ref") or inp.get("ref") or inp.get("item_id") - if item_ref and tool_name in ( - "get_plan_item", - "update_plan_item", - "delete_plan_item", - "move_plan_item", - ): + if item_ref and tool_name in ("get_plan_item", "update_plan_item", + "delete_plan_item", "move_plan_item"): if self.context_store: item = self.context_store.resolve_plan_item(str(item_ref), campaign_id=campaign_id) if item: @@ -784,9 +806,8 @@ def tool_label(self, tool_name: str, tool_input: dict) -> str: return "" - async def _execute_single_tool( - self, tool_name: str, tool_input: dict, context: dict | None = None - ) -> str: + async def _execute_single_tool(self, tool_name: str, tool_input: Dict, + context: Optional[Dict] = None) -> str: """Execute a single tool call using the tool registry. Parameters @@ -806,13 +827,13 @@ async def _execute_single_tool( def _track_token_usage(self, response): """Track token usage from API response, including cache metrics.""" - if hasattr(response, "usage"): + if hasattr(response, 'usage'): usage = response.usage self.total_input_tokens += usage.input_tokens self.total_output_tokens += usage.output_tokens self.api_call_count += 1 - self.cache_creation_tokens += getattr(usage, "cache_creation_input_tokens", 0) - self.cache_read_tokens += getattr(usage, "cache_read_input_tokens", 0) + self.cache_creation_tokens += getattr(usage, 'cache_creation_input_tokens', 0) + self.cache_read_tokens += getattr(usage, 'cache_read_input_tokens', 0) @property def current_context_tokens(self) -> int: @@ -823,14 +844,14 @@ def current_context_tokens(self) -> int: conv_chars = 0 for msg in self.conversation_history: - content = msg.get("content", "") + content = msg.get('content', '') if isinstance(content, str): conv_chars += len(content) elif isinstance(content, list): for block in content: if isinstance(block, dict): - conv_chars += len(str(block.get("text", ""))) - elif hasattr(block, "text"): + conv_chars += len(str(block.get('text', ''))) + elif hasattr(block, 'text'): conv_chars += len(str(block.text)) else: conv_chars += len(str(block)) @@ -896,22 +917,19 @@ async def _call_api_with_retry(self, api_func, *args, max_retries=3, **kwargs): try: return await asyncio.to_thread(api_func, *args, **kwargs) except APIStatusError as e: - error_type = getattr(e, "body", {}) + error_type = getattr(e, 'body', {}) if isinstance(error_type, dict): - error_type = error_type.get("error", {}).get("type", "") + error_type = error_type.get('error', {}).get('type', '') is_retryable = ( - error_type in ("overloaded_error", "rate_limit_error") - or "overloaded" in str(e).lower() - or "rate_limit" in str(e).lower() + error_type in ('overloaded_error', 'rate_limit_error') or + 'overloaded' in str(e).lower() or + 'rate_limit' in str(e).lower() ) if is_retryable and attempt < max_retries - 1: - wait_time = retry_delay * (2**attempt) - logger.warning( - f"API error ({error_type}), retrying in {wait_time:.1f}s" - f" (attempt {attempt + 1}/{max_retries})" - ) + wait_time = retry_delay * (2 ** attempt) + logger.warning(f"API error ({error_type}), retrying in {wait_time:.1f}s (attempt {attempt + 1}/{max_retries})") await asyncio.sleep(wait_time) continue diff --git a/gently/settings.py b/gently/settings.py index d197bc0d..33254621 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -4,7 +4,6 @@ All configurable values live here. Override via environment variables prefixed with GENTLY_ (e.g., GENTLY_VIZ_PORT=9090). """ - import os from dataclasses import dataclass, field from pathlib import Path @@ -30,7 +29,6 @@ def _env(key: str, default): @dataclass(frozen=True) class NetworkSettings: """Ports, hosts, and bind addresses.""" - viz_port: int = field(default_factory=lambda: _env("VIZ_PORT", 8080)) viz_host: str = field(default_factory=lambda: _env("VIZ_HOST", "0.0.0.0")) device_port: int = field(default_factory=lambda: _env("DEVICE_PORT", 60610)) @@ -42,10 +40,7 @@ class NetworkSettings: @dataclass(frozen=True) class MeshSettings: """Mesh networking parameters.""" - - broadcast_interval_s: float = field( - default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0) - ) + broadcast_interval_s: float = field(default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0)) replay_window_s: float = field(default_factory=lambda: _env("MESH_REPLAY_WINDOW", 30.0)) reaper_interval_s: float = field(default_factory=lambda: _env("MESH_REAPER_INTERVAL", 10.0)) status_refresh_s: float = field(default_factory=lambda: _env("MESH_STATUS_REFRESH", 30.0)) @@ -56,20 +51,40 @@ class MeshSettings: @dataclass(frozen=True) class ModelSettings: - """Claude model identifiers.""" - - main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-6")) - perception: str = field( - default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-5-20251101") - ) - fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-haiku-4-5-20251001")) - medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-sonnet-4-5-20250929")) + """Claude model identifiers — the single source of truth for every tier. + + Tiers are split by role; capability-first per the latest models: + - main: Claude Fable 5 ($10/$50 per MTok). Per-user-turn reasoning + + tool orchestration (plan mode) and the dopaminergic classifier + stage. Always-on thinking (no thinking budget — control depth + via output_config.effort), ~30%-heavier tokenizer, may refuse + (stop_reason="refusal", empty content); needs ≥30-day org + data retention. + - perception: Opus 4.8 (high-res vision, $5/$25). Highest-frequency tier + (per timepoint); Opus-tier vision for perception accuracy. + - medium: Opus 4.8. Onboarding / wizard summaries. + - fast: Sonnet 4.6 ($3/$15). The cheaper/faster tier — drives the + verifier's parallel ensemble (ensemble_size calls per + verification) and blank-image / summary checks. + + API note: Fable 5 and Opus 4.8 reject thinking budget_tokens and sampling + params (temperature/top_p/top_k) — adaptive thinking only, depth via effort. + Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere (4.6+ + family rejects them). + """ + main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-fable-5")) + perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-8")) + fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-sonnet-4-6")) + medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-opus-4-8")) + # When the main tier (Fable 5) declines a turn (stop_reason="refusal"), the + # main-tier calls transparently retry it on this model instead of surfacing + # the refusal. Empty disables the fallback. + refusal_fallback: str = field(default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8")) @dataclass(frozen=True) class StorageSettings: """File paths for data storage.""" - base_path: Path = field(default_factory=lambda: _env("STORAGE_PATH", Path("D:/Gently3"))) @property @@ -84,7 +99,6 @@ def traces_dir(self) -> Path: @dataclass(frozen=True) class TimeoutSettings: """Timeout values in seconds.""" - plan_execution: int = field(default_factory=lambda: _env("TIMEOUT_PLAN", 300)) rpc_call: int = field(default_factory=lambda: _env("TIMEOUT_RPC", 60)) volume_acquisition: int = field(default_factory=lambda: _env("TIMEOUT_VOLUME", 15)) @@ -94,7 +108,6 @@ class TimeoutSettings: @dataclass(frozen=True) class ApiSettings: """External API configuration.""" - ncbi_tool: str = field(default_factory=lambda: _env("NCBI_TOOL", "gently")) ncbi_email: str = field(default_factory=lambda: _env("NCBI_EMAIL", "pskeshu@gmail.com")) @@ -102,7 +115,6 @@ class ApiSettings: @dataclass(frozen=True) class MlSettings: """Machine learning training parameters.""" - model_cache_dir: Path = field(default_factory=lambda: _env("ML_MODEL_CACHE", Path("models"))) default_batch_size: int = field(default_factory=lambda: _env("ML_BATCH_SIZE", 32)) default_epochs: int = field(default_factory=lambda: _env("ML_EPOCHS", 50)) @@ -112,12 +124,9 @@ class MlSettings: @dataclass(frozen=True) class TransferSettings: """Bulk transfer protocol parameters.""" - transfer_port: int = field(default_factory=lambda: _env("TRANSFER_PORT", 19548)) chunk_size: int = field(default_factory=lambda: _env("TRANSFER_CHUNK_SIZE", 1048576)) # 1MB - max_concurrent_transfers: int = field( - default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4) - ) + max_concurrent_transfers: int = field(default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4)) @dataclass(frozen=True) @@ -133,7 +142,6 @@ class UISettings: @dataclass(frozen=True) class Settings: """Top-level settings container.""" - network: NetworkSettings = field(default_factory=NetworkSettings) mesh: MeshSettings = field(default_factory=MeshSettings) models: ModelSettings = field(default_factory=ModelSettings) diff --git a/gently/ui/web/connection_manager.py b/gently/ui/web/connection_manager.py index 11cc3fe4..00aeaef2 100644 --- a/gently/ui/web/connection_manager.py +++ b/gently/ui/web/connection_manager.py @@ -158,7 +158,10 @@ async def broadcast(self, message: dict): try: await connection.send_text(message_json) except Exception as e: - logger.warning(f"Failed to send to websocket: {e}") + # Expected when a client disconnects/reloads mid-broadcast + # (send after websocket.close). The connection is dropped + # below, so this is debug-level, not a warning. + logger.debug("Dropping a websocket that errored on send (client likely gone): %s", e) disconnected.append(connection) # Remove disconnected clients diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py index 8c66dd45..1fffcb7f 100644 --- a/gently/ui/web/routes/chat.py +++ b/gently/ui/web/routes/chat.py @@ -20,10 +20,12 @@ from pydantic import BaseModel from gently.ui.web.auth import require_control +from gently.settings import settings logger = logging.getLogger(__name__) -CHAT_MODEL = "claude-opus-4-7" +# Per-timepoint VLM chat → perception tier (Opus 4.8); centralized, not hardcoded. +CHAT_MODEL = settings.models.perception SYSTEM_PROMPT = ( "You are helping a biologist interpret a microscopy perception " "assessment of a C. elegans embryo at a specific timepoint. You can " From 0457975b0fa9434cb3a9f63ecc5663083b648c4d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Fri, 12 Jun 2026 19:52:35 +0530 Subject: [PATCH 020/200] Add UX v2 landing screenshot for the PR Co-Authored-By: Claude Fable 5 --- screenshots/ux-v2-landing.png | Bin 0 -> 299550 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 screenshots/ux-v2-landing.png diff --git a/screenshots/ux-v2-landing.png b/screenshots/ux-v2-landing.png new file mode 100644 index 0000000000000000000000000000000000000000..62502ce3dfe59b147cb61f8aa119e4e87b907228 GIT binary patch literal 299550 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

E#T1)1H znJ(RChX+^Z<>364B|G4xGa6I(&5sU@r4LiGBYv4ayW86rtWxgK4e|+zxZCp(Z`Lhu zm1@$`OBr?{BUin(T$=xvh}xL}U_x$z&c_J6^c%HA@zf{%PYYlPtL1V$|5L-8)SB_p z#e8cYzN(bAkE($HPb)ZA&IpCR;u{Jv znHuMqM}p9ua^yrCG1_AbjkF7-U@5gj%7z1GVafU)7f8j1C&V+TX5d0b-1PRK*ox4} z0_w3d6K>ldw6J90A$RJ=0Ds`uHG#?UR9IIU$zrP!QoF~hZm$Qc<>2(;Z~Ri1CaWe4 z-NF7VIumwfO)hzl2C^1d!)V6;;xpn3uYOdPUA>lOPc4XEbpqZ9bS+6OUEIB4mbFNx zd9AlBG3ueo2off_ZoS3Fk1$vMhPb`_))1om_3-4gflWT(8Qw6C>-w}mnikq_t4@bfy#&@*|9H<4PSSyvS?$~l)FCj8Pik#qaNV{k90 z<~+Tba3~X}xaG8=m;3sx~csJczXQLnKG-t0W8qraG!+}sAXrwSeP z--{2p?Sag>V5^3&w;CwfbGlz2 z&r3?fpk<_O!B^-xwEG{FfUkC3CN7^lAuP2=;}N3H2P49eBYJYqpDP7YOd8A^-Nq}> z3U9~zU1BQ!mOuKm_URQ^0~UTuujR}yZgU23=BJm;$xv)`pYB!jY`f^-47`l3bT;OD zkYq5ODrf?rM+e%t>eSHl@)@bI*MAN&+uQ-6i4=#&u=wr{;z+g@h#2Qca^WiQxHr(3x|bWsE~0tbJy1)iZnI4|M8QC0CzLP*u2YxCvmZ6KC^ zO_zemi8QFXmD}O==;A^s4+oj`Ft1Rt~{nq39uf6u~i?J z1*9{y+FcxRZnm1tXQB*lE{4ly*C0GcC=69L;$1P{Ir~f%RoHRueMgXtw=4S@=K2^I z$%yH2>}sYhoQK7O+p-+A3J^l%CpgX=-UPSvdazSRv{TS!YJD*gPg1i6!Q$HAGMZ@N2ky4Fn_`)r)AP z@M5Medk<6L4)dYdnD4jQ&vxU$VNuNWmHsVES*8!}dS7y7&Qy-u_^Y^eq}+WqPOXy4 zb=xt|mu7|p;!>kD5Vjc=N7cBD^Wt?M+ap&b+zcWVbOl*mR?Hy>&InxOZ zMEyZn_ChWvvO$87P7M5wYsl`hP)DxEz3a?TesjylSy%1<3MB9c=f3M6tashCrSiN(r<7hY zvpb%$+^<4&p-=olwp9lBq*S^tR8>(Qys*-_U)01PPQnbHW$1BP5rUeuJx-TqH@p#j z^N!--7vh2_?R36vHZ1GCMBf8;4W}}evSxOCZjuzR_o^OC-!C!^S}f*1>zs`y6e=j6 zcJRENP>8&RBr~PnKQrlBz=>9BRIfr)q+x|jOPDCYR+ih@Iz9PwuLShR^S-cVPAUe% z;-Q%B5t~1Z2EbB#9$607S|YMwPU0**?ft8)+g;l<5#htfapI`akDS=}_#@sS*=u z;94^Sa=_#kZ>_KMXqmCPjv$Uk@;O#$Ub;Z2A5_I}6mlkzItJd79+>zvNAz{aVQ#|{ zf9)8aCH=+|loFh>yBmyAX(?L+6qxa%)Y$7N}O7e4%BQi4!U!rk)g4cL$ z)%A!`tG|hu??H`@bVXw0>9R3D6y>*PyQ2FPjmFvrd{tEa^*%oH{tKsTA3R-Rh2!cwcc1g2kaHkLr&(~= z{ZPR@55u4vG9|*EMnmocz5Ed9Hxpx$x#}#Jg61;!EBzsZ1%5tQsV`codXol;kT-=E zSY-gZzt0LpRke`usY;&B%6-5~dSPq|?|sdU7oSDfVQyh2smqKKZ0FZ9$NN24(kRWZ za_I9=+?|{V&0bYp`bVG9P}EXDetg3(7X(xkGL)hIWHUs{LnH@9xKFTz=T!w?r^CN& z{O099&i(GMIX~C011cU47ct8T;P`r?eQ)Lq^GDtmres;Tw*q2>8I96Ex-z}ADoAw)jVr*YSyCzcmD`uhdZ(Nx70*VFL z?VEC_HFFx7HChhkF`py)0P$=F&skcbVYkR=C(RJ(Q0qhP3cI{=%JF`zsnd}cb4moA z6R8iCr}?}QS_U$nhGykm0(0N>gX4Op;N2VngV@N zp98x%8ZQT9UBV$)=4^o(+~e8H(oej^>f?DCoPPmUpI$S5(IGnCdz$<;OcCdpj03}K z9z2*k!JwTpd|)+PZMoKrz&gAYDT zJNz)$nGV+N;S0gP09)9`?A{akztMe@0DYs9k4MWjcsNun`C_Pzyg9|>9C*K&C3e`S z@h1O7w)gjnd;qlUP-NwhZ%f2l_Eg5GWw)LMn_i0j64ZCj^;`xEUBYUX=G3c`sTV_4 z>}kQv#Uu+bG^0JRRIt^xkxUn{-@b-SxW6sezFk=ByFvE~tdR_&*~JMr4={Wie(!py zV0xdjhurl5k6LQ9x-#Z7370KPYeG~!?81tpOTZdJ*ILeq&bYU;`GxLVU{U8&sKrU@ zJg5gCWd1j*(Q=~DJ9m}h=S`cfi`o6MjOQ1OFffG&p)`f9pf{+#IL>yb51y6O!y>NK zn#;J(e&=)a-FRwO@4PJzPum4ipzRQ|8S-4nWYmd~+9qo;^tpK4Y(>iNwKo>dRZ_4u5VqMyhdpB6cO32 zxgQK-Pf&;g2<(Yc#Y%AGfz1@f8p8tRHz|*zXZq&|r&K2M67YZv5?u2TO?&Q-k*;{6 zJs*Zg&mDKO5Ehpu;~jri2*uE-6fY9WhKcxmi9sjli-ua*mL5N*@^nG36l3<7pIY&QbyknnYaA+!@WkCH<4M(jm_M4}9DRAZ6 zf_jYMbol4ke11ayB|B`v9n}6-_aQ_6?gIl)!OA48xGIlj)Uh(5e}v?9utKpWwn+01 zhPtC1aK7ejf5hShtN#w;>RF=0D@mvt@2=2#Qmc~2n8!zO#01r2`#NKbm>E!^3&0b0 zVSUvaFjH6VQm3>j0g9?zpZ%*iyG;qsvZ|{G{Wh>$GL?XdcD_7RWH{vXI9BBNp?Hv7*Z;Htb%jP#Rg&X?C+w^0OLN%FjV(kau0ldmAlOdnVu31i zD2gD*X`c9YnJ~~)KGo@Pc5gr#^U_moVkO%x(%bTMpKg|X=}@2F8^ZqrDBKv7!J4`0 zqV*`@UF?j|y+9DzaxUrCbB$;V8FcCZ1SFpsKjT}cR6lX4ID&>Q&ix} zXT^T8p>{8Hu1{gntDi){>_5R6RB>=vxe}@#ISgf#7&R%~5DwYRx z=&GNuWV3o)t>f`bC#}pIw@EfcZ7huc(q7o+Zm=J^E52&YbzV*v)SKAP_h&5bn89CE zbg}ZXdd~R>fWDN|TUzZrdTqbgB9A7B`>&)~ct3^qR5LyVES?^cYX52$NaN6S?Y~no z1w3z<$~Q_mjyL4;MG|t~j+lvmPjNVzO#rXEJ3+<258uePLD|85Z}3Pv_uW`Kz=4en zcQ?AtJ9co;#(`EG10zavfK79DVEHWF!FYjwVO4`?k}o8E4iEs74^%K)p*J$u}-JzS-X5zl=-LheV;euYQK9m-e}2YG_heI z3~Zh<8H+&KFyFiNxDa-4Ik`A7Ih^+*?~Zr(-vXYsR!y}=3rss`W;_;(!xX1q_ycOi zH@pyOu{s!$ZY7GO?1`#D{R^T1Hrsc zy?_Q2_AurBW~HE=R%Ab^nQ;UU-l`&E3w3WhdP7H+P8ZGwTpqZV()t8bkB*HSh1srv zboPa9a<>0Gl^T7h+C4Xb?7FHl*G`;SyK#4tL(vy1y$f7#14CEzF>JpUgK(tBn9n`4 z>7(nP2A81OBh#lc}?0NJVDc3D!!g;Npj{J6TdpQk=F;nY%A1Xt>3VLHU$8yzLF0y>&kR&|F_=eWgz8lC4fl59^5+{g z_Y_;gUha1hzF_}%5yV~2CP_g@ z4H7D9X=|Npr=3e$dM|pd$Sf>@Ht6>(kh|n6ea*(7J zLqh)+pe^H;(y8t+<#};%@>xwS+m06&j?87Qs}}l!TVWxM#zMpEFt>v6z-Y#T|0>^Lp{nn^|*3t4r zBcP%djq_%Y&v)wOCUg-AxeO7J4TY%|m`;9a)f-}&W%X6xTJ3y^g+-e#yr@sG3h2EU zjap75<%Vxj|AkaqEF4Jw$`seZuQfh!_l^C^3u>6 za!azyskDngVzNleL;Z}`g<@ZF^b?p^i2);ZYW)XH!edmZIR;j&)Lxq-!-z3tqo>0z;<_Y7UdfFx7&o@te3jbvidY?B8?Wm%>=0h&^=| z3urUVVA%YRLst9%VE3)A+nYTaXw7I%{jFu2-OK-`0Bu_Dt9{M7jx%8+Rs5xZo9_)x zuI2u31<`vJdyirdNQ@6nBKx}aIfQG=;7~DLe#IX5i(E98yCe{FM45)lKhv3KfNgw8 zXhl`Z3n%q1$L3hFoqjjJF^Wl3y2x>mEx8^3=*ur16#q>~!fRCYBpNvOD@oFcD&>32 z?MMFE?T%{l$9A8*X&9*re3HNp7Va>#xmZMfT5ly|Skv)+- zR0-|1@ft=gKM^JgAHOD&2_p9?`8x;U!oXN)(s+gSQ*fJ)&LvM^&@na!qZ)Y}4E_dh z-Pk5;V~H7*hkrC#I;T?-LsGpvtQ&wf7vqQIexwsuA&+pj=x!}1$21I1qt}IB9BF;E z9-eg{A{yySxe0T6Gd=(QEXHg<@OI6+E*Fj+%3AI235UDGq1f~*0BuGD9A!Y}dvjIe zO<_|Y3ngBZgK7=j*E`!@Jbky<&TBjNO2S%%=YfZ4_>+L^?(7wEnA%6NkE-9J5u$nD zc1x-SyG(Xq!_BS_ zU?Oy3fJ($SOCArN$YJm%DP-jHm`>K`E1SKWy~21nT0;9$##C0Vg7&;uq-^!~YFUc&B6ksNj8R_rEuV3z{WZ1!{$6+m@TD4TBK#{U zDXo3qt7h}LI)dULhvvCL=k-zPCg>*T+po99wZ5eh`qlhKM>;j0N20)xP~viFDLdpp zs$Tny!I&mIY;d&E9^Z(Ox_StlGF+4muxY#wvc;5x*0@J;;Z>3>Nz>wlXPr^!z|9&P z-p3#1D#w=pDNBNfEbD*%^;g)4APhRc+UJGHg*Wo@AU($f8vJsaP^oqCv{GVbsS#0M%Uo8y zby`&-C3a!%9EM~HrKPbw2?mxI>3AhJNvRv{KLtsoOmRoDYQt5W0VFAGl1?i%`O)8i zW+`dfq9@fhKA^_aZzonW7S12yT?%9i#Jl=h9o(0hYq7%r;D*XAOE*C7>T=>X(uODZN&ai2Sjm5kqlk%#kL0Qsl~BZQ=l=yS509j zqNjW9A1M+{CN#+J%sC@ROtgE%sFTnpab9uAp zzm{^c@SB$gtqy%NkcV^22h>5aYcivE*pJK}V`>_jbCrls!U+?ekK=gagq|-R!e#oo zZsXtO@mctt4o^y^;soD-H6;cNxs7Iha387o&nHzrEpI)G%t|^p+xS-Xv9<1!)?DH! z5;l=9ORRG)P6Q}C40g3El~6irHvJ@(;gVB!0$y#?CC;_};=?9#C&v(U8Rg%yS=@D4 zMORL5UJCVjtiWqWyGzwn?l?=qyfh~@a#?Ku*8%}!!EhAdDU{<(N%I&RpN$vfJWirX zMLEe-GRs|DZ&Wg=GXogeQoDe6qK+N zJ>Hci4dg9#BSS`^Yh>5XDWmtLBZdn+M}l*=NLvkep%l)%w0vH& zn;N@?tIbF+qs}pT=47{|*nqMS4I*!$DN>uGaY_C_bzO%&X@nuGSzc|i0Q=dly(1CL z?i|16i(LuYHKq64X`=$P(c=RgE!>dqerN+^eXpF!6nLKGkQw2y-E0JPFX!e8m`|^n zSD$Y#$h5>@rfYYYC1>6_kLW#1XzT_CASyaz8i;t@%t~O{hX`uA{s}+bbiI~7fdw=& zmrhv%_kE(1n-7CJuVO$z%VhJ~b-kuEou0?9YDQ+(7`# z9#|@aSLo#Nr?Lf*r1w^)CfDZdcIu+ShBJ(8X|>y&hEKs4pc>aElgZw>&~iL50U2?BKg_K{+cx zfmDh*(If2d>%W!yh^I0FStnx4_FM2WK?U(A?u_PC#W2*nzNkH`DIszC^Hf%;BN}p ziPe#cEdOmp!Y)78ldzW8*3-UvQ+Y*wZ)%Rc&A9|kh4}M?Fz%SFCkztBT#;IEI!xg~ zm~^?Qo8Rnoc87iEDR-=rf)W$I#`)snudK`8o&j?>W{E2B25&E}uN%$_!M4bX!j_li zrB$}^B;Ed%Bvw#HHs$?EeV}O0Yw$DWND~gr*kq6r+G%cUza^yB6}I{>JjP z1i~an0i)-hj41tX6#G1(B5sbU=UL%nDxMPrAAuZx%JVI#V4q98?N5!bl=K!`4Gukj zzT?gAItQ*t<#+DgoeFD5T9-hcZl3uClaum2luisf%)nV=UYajvlJ`F*CU2<#r^_ld z)H5GdX~Z2}9J;?4mQ4qoBW&^eNT#v>iB`6#irM`W%PkZTr?}1F5>n_%EsR_uO-7KK zIC2K_yFGTc<2yspms7&L^B3D>sDTklY!w;8{kG1~9s7C!BEcmT% zwYOV3VUPT6Rt3Ipx{T;>;K*Wu$b3IyExSJg6}}Qx%kz~<4HwDz5&ot+UU-wxUD^@G z`l7&EKo4|jju4+L#}X)QY44SOZE0$a7qD-VFw5)ga6I%K#f*_tI%mDa3}I-f>!^33 zOE{o}9$7L|^mDK_+)CmXIweP>0h@YF(-Vd`tizMG6xl4T3?0ac+7-|+(Wt4MwoQr< zv4}C@YvqCQ8J8CEfQ|PF2TMa3pLhhjXgx|9Oxb(X0d)Tw>mcz|&Wcp~rH8rStQCIX z8VBn;w9+?N&B^uMis2bJdAQ}+0#DmFG&T#@+b$fdz?`1~axjDfWUwyBv&2!mQSRK6<*%59p9vQi1Vybj0N zbwIb0dD1sJ^7~P`BV>&9lNdbLmu&jq?7qK5VQqInjhv2XWMciEhbbVCFyRe^QG8ZS z(KwWQI4nb3PZJV<_9U5d-Yh7VCl!S+*bH&Z8FJRI)W`gfH6D`B#VV}D5q|B)@_C&b zX}&kI@`ALn>49VH4c>vVdJHuwAmx*~HEER8Mv6{vi~D6P|Fft#JMY)gg!1DWd*A1< zT+lvGN@nSA3`7s;ax=Kg@|$Z&W}J=huQK2B!i^p9TilIqn?}#Y2WP-!UDGe&foYLv z<{X13XhscA7?E^ZfHHe;dxg0hE%xtUW^^KRu5K4vGdh}uy5SEyQfk{@KnnlL=jOe) zpA$N+y$x4ip1h$%-2*|7+W?jf)!YPI*U4d$;vsmQDc2XJu@FSy|6GSe;rE|xE8u?J zby?&6pFnAS)F`mdRT(1Xd#ST>y;>L6fgTn|4w)oCk6K^Q{JVY{`-{Kzfrtj;e+@3Y z+2JFZ*6em+tWcpff<#mFkxRYcXg;t;g3P;`Ho02#v%9By8MindZN%@;1ZDi7Sk2_**i3rMDT-@ zbaOEp3rSv{xYjFN0I}937RN}qg%G;!_0jq3RHLVf?Os| z91W}lf1Tz#pWL`^bq7ofQ4X-;XSKcrn@*$%-VVMMf~C{hl>FBbPhi?grQoG!~bp5cEcSrY=Gze zkSTL^%#|ma+Qt9&Esn*Y`;cJUOF^<J2mc}`4bv*0>H|1|xW7KY$; zqR&R7A#m0m zbOvCOZ}?H^-e**b0B`AaU3X`;?*o&eAw>6UPsILIItPHo-sjQ18$6J0O9A!Y@Zh;! zrvJ1;>Hm7wV?RFZ@wR0D`dT1`_P!?IcLsgS`=J95Z}$P^FVrO z*O}>z2K1ZgdTcASY&z}?D@@IH0(>ccVsOH{Ofea}Vo`u~H`Zk(FmiK!9+xOUi>VnN zC(!?by&}2RV&_Qn^)7zX8G@0@weS0gIE1IsjI(-^9kiU_*wTu?x>E! z8WAt~O~u*V*OM=NC7(%6-(TS2oG|4!Sy`lcjE3~wvMPukk`k{njTbk|fOXi;;OkuS z{|rp-ZhXZtg!!nCpEXse&?*KWGFv`IB2ijkKISmq~VNDrIh_#4b9hUDYnw z>w+os2*{Llh*(^$8w=&EqY`EssUOIjN^@KCQ4Ae*eKBYLKpC$+C?$zm$aAsG$&Psf zt1x=J;(uf-0-U>4D9q#*sW-pwR#no!pRcTGg=RCEm9@S{Q6-@ZSc0vfy{Wi6mw&09 zH{)T=g7v3+GQ>Vtn|#nR6wA?0oTW6^ZQi}6$MOI^)BR$#$!)3vpzC*=JkuSF_L`y5 zdGm6;GcWl)mR!OA<|?aU8S>Nv1VK4VNOhdu#uIQFDk$mJ`GBA;Zr`g0HcM*ZcqD)w zZxGVIXApjJG%TbE?T*z##%FvU77VtVZDnxWvNCe)3OG^?oc5S%{raX_Viip@_~i`x z2BLEOrin_kU#HIPe2OgXHV&Yz1N!zxpVR$RUDqKHAo~LyHkNU=_jLOEorZtS+aN+W)%-y@E<_3^{rVZZURU9mb8+m*Gfzlpe446mwb?^-mhDBld z@cvMm4arFDao}aqXA*ESiM)1X^9}u1MxO=*juxmJc!D^j@tb~Y6+ZvR+Uz9ZhrsMp z%@|%rR4!-x$r*2;5lr_0|Jve z&HHR|o{lVLRl!EiPxFa{EOmNRjhvv+r32S7kaUNcjT%3ClrRM*Bhv`$=NwB-&3NEn zS6yAx2N;C2vN4k-*!h~?p)y{VNVMfS{@=8l{AQ;~<9~IZS*u{*_KBZ~6SeQ6!--g| zdX7e3wBS6TUE2AyQ6>|=b5{pDeR@4@bZK)8lhiZs@+W5U_#>oCwG}-&>Z5-#0YP0b zqltVTHojZi(05oUbn6^^C-e#toVL%KG=uHvQs4|BP;9iv~eb1h|( z4$G8k%4!%1enJwL`b1Lqk5m}_&$n} zbv+lmU_Zn4$vpJDfa7@BYXL%l`IWA_A^o>@y*vCF(Vmf!3!HJaj!T2AX)s%kX}EL? zUj;QIOe5-bjsdxX`9}!hoxmIh0=i}NYlkOzfiUu%UB~JA7LE!OAh1Y$ZJU z1FhI2WOH0ogueOC5)K8xTf?cC7UZ{nyPAQe_g6xXQ(_8ChzrHGG^=KXu>1-vRi=)4 znyJc`iLqDb%Trz7f|s>S`bZG9Pd%qPB_6_g8P}H{cWeMWUkZHj9gw+Gk0b0YowYzQ z81pV%K~gV6uZc@K)c=$+Qp>QFPtSQ^yN>5eA{?P{e|9c0@&CCX{0%E=G$&Pc|h{g}qQHLks6s*u1L3Ianmg~`_bi_XLvjXXUXujvacSy>#+#`h@Cq4cJN=$YD-Uo(P`q) zUtZGsF7I{w4>iKD;gjXE_&wwMsmZE&x6pOjc5I(_q^0nIrR}pgs=LX*8pZ7gKr@gy zn+-pIFziTWO(!~?o()3UreRP~P|4`^71b_s>Id!iUw+{?eUjgO zT#0(}Tn0MwylC_hNZJCTkBwAf*yri~MA>C(<7s@*v;6VGQr13kTE-|YT4AL9?ZzU_ zdujMPkElbwaE5~Y{L-$2twWn6W;pJz*zjdO0_^y-@_GrW6`F$oEX#*8KY!xmK+`la znL0s7TM|3t$;crm^QCPQEW*TA`s9L-Ws)^(a`U{nr`jk&5sUT9gP#?sSzt-uyLq~%3%m!1${HlT+v6CO;(GWE` zRHp6f6^S|4^`(;Fuy#4B(~tNjlI^z$;jAlRlhga6b&;=yqEuY88x4@Ew4b%vfpoN+A{^^_jwB%Job|LO!z0gRJ<6>S_4jAyQt7{hHgc!bbeLYjzcE?m z={5@{L1H)Gi>f22Yyja7?%;ho+5f#pfNCmNIb zWB1=#D=!U5_>DivE&FVSfrLOVrYU(yfe*MvOGPvE`p_#&R2U~#8NQ2M9JRFG_~T~( zH%64U@JW+(<2bvyUtklt*Qr}MLC%Sls0KQfaDl$s=y)N1tn#TPOaaD(RuKNQ4L=R; zGib@wConp@zmE_t(%3H|hasc*JBc~BP(j@+HYy|l>De0nVhb)>AYjRpDNd~|nKSD2 zOIOWC{CJmmllb%g!rgcRD|o1#^DqW#C8;)|_xdxncu}(Wn>n6&dnw|8oL*?Gc!3}5 z3+sae*c&;kIH`g=o+gZDiLjqKnLh;Nrsk#-)~cpP$vg)FAoIN;Eg?jGh>Sdc{4ttcTE|x*1l#0&ELIxG%t$ua zO>{`!emgnT@`J=|~*17kw6^_pNMn&fbHyw=~%sxrT;E<^F z3&pSK5bO6aMhaft^^Zu1vG(ZIm}EO?R+B8n+sZD|?%z~mzcjxDHY!DlX29VS^RMw5 z*HyyF=zQjML?E9wwJcO2YLZL9iWOtuNh|p*z-Xh|c;*X{;(Po>xMs3@XVRD>c3RF7_Ju#{iS3oQ}2 zvsBjg5FfPabr;4m(jI&=<>B_5RWA*>JMWA>cG$#YgjJUKRr?5ax`~m$k_>C~x;!$@HWk!{eP<-calswejDdt(2A=Fb(&m%ig|*POKW#7tJ0e$uhX1+j;AJw{0RlhTXh z`(x!reDbqjz44~oR1>d%^-HdN_9ckg8?o?o3EB@KXJZ+3(-UCQTVQIp%9!3MGUd=H z6>wC)ONgeN-jhKXi=U-3bST8{b?HnF2BLDrDIF-C43CzOzoR#_PT0|JsW9>G52dGY zKtziVxHz-v#`#$iXF`7dP88Dysfj=aBwD83GmOLVBfWW;OFOtP$X^C_ek-)#3ee zMIO3lwg9bPzn#sIA+mR$+_8GPfZTLz#duonuZFe^eo(2 z6>VHLj%HX)kTdWxL11n4Tum5e=;kC-Ujy+cc!4~;K_`G!qSSERZlNM9;)d~?@|Za6 zk{3gK(5>28o|3L*I9F*tBI?|mPo6xGId{u~L>A^{mDOyz(y@zPL6V)#twxtSIqLSFvC}wO|f~}9x1cvTz=Q5rM->~wqtwV z4xr@b#%nT1$JS(EtcM-Ap_t++d&}aMVS!oJ((oB(tq-h*JY1`+Kc9Av1S53Xq99>2 ze&d!_3Sj4`g5TPu2tz_AC74lrZPcCSBcMtkGiQ39-iWUJbBeBh3O2u2~5E z6jKStq_mRXipLGdjaB%S<`=(Lr%G?`^J*b@H_4`}!Yd(aJjZ+S_ z$YPV#_s+3kPZbu-4AO65@UiyQv2c~`Xe*c2A2L7P@_XY^KXI(NR zH-E~V*35V~XFki~2M}vL=Wt5$$pVi=YI36!!x|=s&YX|3jS*h* ziO_azMGn4&A9HXQ27}$X>vW7clmpuTg6qT>60d^Y zDHwuW)zR7sH70G|{Z|g=-6@Vt0?cZtp%@cb4S?|gs{v34u$p0x8T=jhOWms@rMk!% zjufAJ*K>OzUS6BQxsLUlE?%jexeW^E*B{*1gPO(b;JG{z6k@%3D8qWB8WTqy?nKGiZt_qDI z(WIhNeNs(ITr0?-llnk35B%gNO*isH^oU@9t|`L0;lL^VJtKTaB`}zKo69bnJj*q7K&;rJF4#d!*aJ;R6S@$>5MxNIw%#`q13vxp!61clmn8gHZ2~*Oq zj2&SH6Jo19xn(&FSc#8h=fNyiS!&4BRgqvRmMrDTCkPR4HM-o%{X^@&*0oMN9p*mp zbFMV>oyEq$e{1oNwb`?w;-^6!b1HubMHxidRaN_#pqD<5hYlV~sF&n5d-YuMf+6(m zNaoF*PspPCS{GN|p#G)T$;mRZMU0zOS#UNM#VwWt<*!I`CTW+)pWbbrm?8m7>_i& zOp#^^F#0bGSO8!GQD9+!GUz*kh5q^t9m+rG1y!Q>!CPqwvvH}V zkS4`v2AMwpi1!Y{xas|u-vl*j8B;*>qF<04J3uiFD20p~V|r$R zX+>aJ7edq~qZUqa<0WibM`l6#ROp$B0eX0Ldjx`n(2-S0to)SjeO`p>8ZZNXCfa{PG@n!~L|m&^S( z!V`l`p)=1gctkB*%&>rSG2fU%dh++Q2E7}qq`IPZJ3V{C48rWHD!dAG>H6&J#Zw6S zWo*rsfy+Lc$NzUe5^P^W-xq}(}BDRE?0)@EM=yIpg90yNG^6bGjq3NDW8K#)mn=-vMUu<@m(`dZi zZl)gK5o6aQr=}uc;9nY8-^`#1)P#P+(ZvA90SWnS0lQ0^d1rOn<2sVGM$+5=oYjV1P!12-<8c9O0nZP~0w=>bnS{&{Qj- zQ>tFD^v0CRz{(n6U}}t?ip8wcUX19K{yH8hS>X41LFbwmi?<%0ks@Tr#7SYK+l*+~ zgENtu5~u@eL8h2|)yBL(&!I!O`ljQ3p)i8u0**={nq<=O;ad z>IlWzCM9gF3y#Wey>-Nb;G;YBF~SGv!jh26sbgt?v6Qy;e2mn|=EEY^qDJ3e$2p_Q zN4G6DWZ_9%s5mgoLbEk8i@md+tPJLgK{}bg@+dkBeuXxV7?E z?;uhJA51Vo>%V=3$?jUlra42N+}x`)x7?k{N+o53C8yb|_5I znnhz>a{fp!u~}y^@^~;Z->e<8&c-nD(bxApH-mXIX|@Ds#(o7Y_Oy<2Z9^=RP$3n| z@V;oq0ZIEHbg5P5*D&#Rn4wQux~I|@k}#W|!s}%7(bpW)XKZmb=xzGbzqe#l^WdstWv81=biIU(cQsFYFIBZb;4vrqX6;ox`o zEPN;vaH3s7FWoJf`^`jJk|Y)IB()G6{dDC1p$;|Z4x^$wxH?yh(jid41|1{y8~I1m ziLo5XNl(c-04eQR_(%m98Ii=O$#P&)SZ~~0@})Lm;c9lEZ%y;fBP5hSCsCN4BPdhZ zQe@VP>I3g8;jAiR#g47{IwvTw;u$#5mC&W)53e&Vs&$ zYel$8JuonJGK?As4b6Eb~+*06n9lY_kBQXMGAaB@tA{WcBjfQ zvUZy>@yMgntv)6w%)knW(`{dlK7PUU3Nhbvp;z=Oi%AtpIAqCFD|(g3C(WwlN9O)~ zpg^Q7b=H!q7j&53#t+oTtfz^uSB@k1YJAZvL#1VaNel16+-e9aw30Qmb32upb=3)^ z3<$=*av8<|2obRyD9p+k8(`|+lL=;PPfLv97|ZRK(<4rI*^+nW=_EwCkHnt@LKoT_vH{Vgky|m~+SVFI zqG3%3lC~s9c?hm*T1^U9!W`k4`kRtUdFJ_9Ba#{^O1DNZMyKV#LkG(ev~81f zk-+J64qd7Q+Fn|84Aqx(@X^h4wS4vYJnGL!&@sXXlz!5Zqglt+n3s=7!>tKMG|xQW z0rZ_+6|xX+X)1pTvn&k^ixqgY7J;^E39>#Yx)jKq9mbjl<{U&Y(*LX(YRp`7M)NN} zx@)g@O{Ob}CvN3kk-%BOK&*I-u6WXN2=1p7Rhf1LgboH>jV~Cd>G{Up&bKn?fMi87 zDYP+Wn3}Venolv0=7CUvXdBEDh*nJvj0p-eumTcwixy!%D}*9#bd$%u9! zm#wZwatylf=O0?1YVnUiZWCFfQgOECv5Vg{7L|5}S`VNx!wl>ip2TpB#_hPs%*xBj zDi$yQhCaDPbU!`9Wi9oaJQAE&qGw~FmB65HAPtt{v`3O|$VUFl%GuY=LZaYL)nR%o zB)m#O_iEVIDx#(<&q}+5PLwdE0t}t8W#G{-$FX$B9W`k7w$+(<;shcFk?b40OlS14 z0NupfJS>;a?E1m4XDYg-D}R02$G&{bK3epl#xcb~h!xS2vhuV6vIL)yAzO46+XBa0 zRQ?q8exg?AxAm;0l?qA+P0|tVuZyyTf*^E=7Y>YH2#f{5OE*^-C8L~S9(u_$cPtPY zcMdtYpOMS7QUZwa*HDD&ISrxs4e7RG?1|2P=uhnbn=AF>XfURgHn{KHa)31sx%1Y{#(sqJ7lkfF9=Kj$Y+9f%$7g zh32q}c{(>a_h;v=)P>yMFcVdOiv(;Gv!1gj|<3Cu_T@TuI}Q34F=d-bf!;Svh-qaVyRM@ zS)$gPDu+Wo6grCmDUKH1t6>W_w2j158t~pBxY+>^!$*m3gq|E7Pl@e0Ws;&48Y!KO z33D_dgRXHYI2z8Tr}Sj>SU6^?LFDzA^i~bDK8Dmr`9M<5sD`T_pD2AG=evw)yB0F!c|%2(<|~Jcd6g1WJ$mRobIP4@E>31EhGwo_0q80L`Xnso z%?85Iie%&N3Z_Z{%qhcwrKN8@kBnDr9GQ(9a%?C2iZtp3KgQ`F{3Fy%7OK* z0|7P>pNtiExi+Ni%^3H{SSOe!8^~#rWkk$7NIM3o_7y@0u=H9udWHG=8LLx))_+rw z7AAvJy&lbE^ePRta<3}FjP(vZ1nkT$tA)M{A<3|OXv&4eOe07rvq$&QBag`(`T|yv zKXswwfY{lC_g8C?jiopEAso!#$rT>Y?$MDQ#U_k{Vvkx`lp-59l%;%s z-Y=WZg=GFAu;^_$(!crpJOjUmt@E>(rklPim!E}CD6=WN{%~lI0*Lx8F5Ot0E)~J? zfyQNGo*cmxjxHU<$eJe*F&9S%#|(NH$DI1|+lw|G#DV*asFR-HL!)DIL)hj4x^#uN z=Qp8Chf+RKx}ty%kHjRGb1kSPZp~@UV~md){m{sQ);yxmLa0UY1+U05DbAJST0Wf- zWTs%731w$E%DE|mOoMi4dFh{_B3siw#|i=~fQ)fVy0emiT#L+-|3_1_O=%p!`NO{w zx+|x--gHV77~z)B zs`ogS``&2%*Sgl#nGebtT@AXkh3=x7D!`hYJ&gn)D{eJYZFPNUVVqDyjrmaP$ueV7 z%+dyqLm0oAjn7^%e?Vt)HaUXXjdu|y=bpORSal;al>h(`07*naRO$x0Qx_gZ``}kN z5IVUz+j}B3*d*Y9Y7g&GiZ}xdD0mENdlQ;|AD{vO$!wef^Nee{T!`QSSd05zok13T~ z4&@#W;~J1?yiVd2Gmf(apZxCtMow$XO~OKAOOEv#`BO%HFU~}Z0Y27D9C~M^n3N2h znQrwAz*!~0Ll1*rH5HSv;%dvYHv>;IO*R&8CboV$qY#tun8KJcu(0?-IV%@>MXypq z6x~0wZdd|zxIG3L3&639UWN3Z9lc7xxT9B5`@tesDX6G?lOfh(7-i>Wi1{q2XJfvGyaXUg&t6% zx*4?rTQIxvaoJ>sHZmGT^1HX^xBd2h?u$CVRDW@of25WmSf zqre!=;2`)={D`Z^#m3R1gM-t9py-J|zR{E$1|WPu*I@*wClIkyCR82Ndf+ICy*9twGjeOE*G~o3Zw`h^~LW~vWsw-m$lfE9!y#jzr z!sK--dxx6hLsq%Ye6l@_u^fFBG@C3%W~eAvB+d^@S%q)NW|eDt#j+*1VS-!Xa#n6; zHHNs4pDa{>^*QSn!Z53{uCll!t7Df&D?zQNNyVmS3-@CeOR5axIoP7Ma;oy1ojF;C zRQ_hmgvr_urB#vFC=&$+O82}WeS^SR@aDV{>Bf`Uf<<(iWUuxx=;xB5BCpq1OZlX> zod=|H1Q^Fx)XsvOh{;tLB96-e6Q205@N*+V`s(~ z`S6UqFi>%>zIuqfjsF3X;Z_)0dJ4eGd>IREv8x;@S4aaDeuprm)T)SN4o-7LDojIB{WxjAoU`0D*!yD z)Gea9GiID?dzBf(L2!r8{B%a>!y!K*OVD+W0C@5RNa>uP#l7g^Ff<~$bRZ+e`yib> zN=KqN6b2Ea+oKG2P8J<;hYse6NW^kXx>;Z6@sT<*PA2IxKggylO9N_GVY)h3D_S2b z@iFM$nnM`6J@WMw1N4B#0wWS1S?5$We|=uc$!XJ>PD*SEmZee4bS<{yj6nH?P}#aO ztF1IWot95{3 z`O;R>W$4>$=-6?^G&b)$bM~|Yh5XC(-d8`J9$*+DR8n2J3<0Pn&sZMyWlSsTcGhlO z#%euXv$tvsRfAu(ER0G}_k^sR+O-mbq^~b=FmMK--acdUf{8O0yP1?iZX5TXO*537 zl*2*lB%9Kb#~No|x}h2e#+8TV%gz8My=9Poud2g{Z|RJVsOOHFyn*0GiApJC)N;?m zBBK+kpFXS0-5h$=Ni+C@kTDRdgNZw{#x!CZWR@|T?&HI<9#Skq&GN*ee3USd!vI-m zr1Ttj=ssM*|=qwMajU?W&fs%%GtdLSn9ODwsdgyWnR(O|;k?vy|t19wDTF$**I-Ch% zaOR>{{r&%JVOl;omgtphQi2cW$eyTC7QO1kDj+6yU=;@J7z&9ogn0C-oMB8tPr$CX z_4Zp^Fe1iSqgNhn&^yZ3qw(n{)SM}zNr-iAd@=-}S3VB<{&fV5{)++gJ*P==KqGSmY5hJqsKhvg&Y(%$%}kC3t5m_-8RM)k1EpD@zz4TMGiK4!UB&}~L|Z;A=#3FRc9 zgj*IwN3$El7(Z50J*!@)UK^Y-z`i$U!QV@*mvxo6C)tZmkFnTdSxIjtG6bN4B-I}B z#f)6xkGl=W@dd7}^f+Lv-W2G{DO>ol&sL8lEb(^|SZRv|cgN~7!_f$yjfE(;WY*=9 zQsh{ebg?R6*3+4hMT4>d-}l`>FwW!aKuw)XhAhF!F(W6-gMs$3MXxP?4^`X;#e>IecrF+RdbYJ}nnV2ldij}g;k%t-o>CVSw7rKz0hsC-JL#)Ld zo4`o9RzY!TODtkJw8fxJ+TTm1vw)u#=gbK2OkoByGUFl#sD;6QaVC?n@)l!r{Vam? z_!c&7;)r9De3n6R0IOTH2#Z*S+{Yp&gje!nDtZN(@}ykQ-b@IuB%6#nX+By09#-Ph z^73F7pDZn6HF=Ux+qChd$X-^xyIDPY#Te_f-eH7ccHJI4%OIF}UnL3?;!!VpRly|6 zN7=8k8HrofT2%Y69}6Q(=Hq>Um=zsx9I zE0m**sqxZPM!8W8iR+BA$*^vj%BXVb+$h5JS%ME3bR2ymGE$$pbTUT5jOZv^bfLE) z$A>mOP#;Af-B5(W1E#F-_6TmiH!}4v`anF9; z&U%(LBET|7D$0kUJ{jGxD6;;H`W2lKEHYCJKA7S&^z$+27qu2L>5ZArymS$pGnWIs zk}zk5A*&Dr&ys1F2JbIoc=hw)d1Yu#w#0JAI*8XP#R7bM?<^|@uwrV!B320bO6?X< zv#zIN5H)-xtidEt7`g;>W>O$bido(!8xwykJ~Pxc$ql>kK`0@6x%KO2p<|OU_D-Rz zLJ4yls1TqD@C_PsH@4)i9x|8d9Xqy?ygykTyENAi>P~@t#^Yi)NwAtFkROv`J?@3V z08PxEd(5`ka8y>skAovl0}sWdt(o$1aymWDGTtBMcTV8z}BE z?;PJi7)_TNSPnnzqJqWbL|1QbRz~1r8SOEBz-W4LFWAoy1B]OO$H9RI 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 ?ee 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�VM#dZfYn4M;>BYkT1x6>Hj9h z6*r&QtKtraOIQYJ>ea}X2c;1xd7s!TF*vhODN16vv|3akBXYu{UVSt=30P7eNp}*k zB;XP(Uy01yC4L_t*YlNcnpGf9M@g|S{FG*Fek9C*UYj)?Tuur_A``$}vV z=r|1xOK-HSyQL(}sL5EpH7wLbxOo_C;dJ$c{F20Rg_313`+3Gb0V5k2-{1%6Rz=H< zn1{M)HzoRb?lSdCw_jMySxd%)^kB3r)T<}<>UbQ8y^@bpuO=peKvA!R6noW>!S3_& zpm+ostj%%CyM(P*BL&PCLBk>mSb#GNRr8)jdXZ2r z)Uc9=3Yx^%*)Gs=8X8uLa25buKAyu7CE{4+y~2pX{-gaRr=Yw;fLfy(D`Razzjo*@ zjKK#D?5=7mGSUUQOu#}jT&T3G1j9X;B_|<$>eYc)K>_Qc*9OI*Gy(c2SG^Kb_J_w_ z37B8v}COXQ?FDxR220JyxNsCo{sXE&adTY+MOu*T20ih_G@`8=iP&2;JoW#j<{TllK-lApe0bp_y*IyEk*RJ#1;% z`$n}tKT=IGG%nEH_^gwAtlM4Gi3b3BmJ=`NpbJ`2R~^0L2GqoCS-3ze)WYl(cqOb_ z*rZPFCEMi!^~#I(Lt|dk;>^Ysr@G+{f+d8Jvc^!aEaqe=TIyAZh>2$;^J@_mu*wrQ z`WPG~Y|SAdo(jok))mg6LK+rjRy=77(+s@z#D`RIEhcNeN;Auu2XdU+EK3e809p~s zwVThk?1F?&4NHinNEg}$Fw3!Q^{8PUC996E|w zb+YT_tv`l=t?%Zdm9zqde2(Gg(DVo()dkWO+r$T{SLB4O>Qgo$&IadoOs8QznUz6` z&@;)SlwOl8Jhf4C+Wg*Ml%&o?1Mm{@gTD?!1XfkU64O{aPi3YFKzAVkgr8(0MP#se zC?HN~Epb?Y9-)MGVhqCi_mO-WRutjP(tUFgwrf_hjji1+c$qOwt!>d)j?Uag#xiuC zd+Eqw3t@>_7r9W$njI;n>nLVDxC*1H@&2Nf^wF)N4v5PEj&#Kt|5RCnOE!kHpxu~p z+Kz3^mVK*w)p!QMdvg#5lwX!e$LiCDt3?g4{GsqzULItDr-GH)22}dp+SLOG;5D$` zXJqz`Sj}DDvqc1Mmkvp)D;c5w+~^kxUlVUb{w3ni#KfXX#V==`R!p04T-vZDGh3># z;Z#60p^dPObohHd?B^`QyR3UVU2Ih>*$Y=p@}r$H;EPdU&`QiZOsJKr&`C~CL|tJCxv@nyE4uy-Ss`S>sa`pyD{b(ZY3cf%iIAaQ37BhQ zpjmQ$1273huBD**+pyQ){> z?PyZxwcEv<>jyW*00B#A8kSMO66vU6F$JuN2kjVJJfJ`AIIO#CSR&OptgwJ3Vw!3; z@>rotrrjmNSbqH)RyE76yM!_0g134*vjH^$D@?vKR6qP1WvtWLeM>lkzaeHlXo2`X z#W29ny-Cl(9iI632-20XYUl2+4HlC0C?AEI+!e7`nm zSQF1c4T~jUiNG3G^QK0M!`ktfwbLq_0Bl~JE;V=5*%%@YJrrvt}D9+V9&wHWuM`(ETNLn9?z_9qPy43_NegR>)gR&;%mNTxDt`>S_nBatudDJVf z^9}XtV(Cg;4YD@nASpX2+ro$>0R$|PdL>XcdFqwGcVvosB^;g?SEDV(Vz0z{&r*Zr z!>I;g3CWp>i6yD927;IzhPc!rYpF?PZg*uW z2$yPD&>2h(V#mo3#S)L$1X=1)2s{3I8y&MSmcfvrdT2&tg9xbiy`LE5$@C*>-4L-s z@Lej@@~qqMGjvm@OIJ6Kx{`x75Hj>xfmbW7*7!a`y;5iw$aax>g~eV8GiHhxqg3}w zw+|EpJVMIa^YUQ*I2;1p1S{7MZjDvIlDh?A$xY(0APsA%fCcejN(L}&V)?USBTP6U z6%*62pcJ+_WUCUnOJdz44om8lJMMKt!3#mmL0(3r>hS5qqp^h8iUMN_v=6?G3`7_| zJ(AX7@J7AYR~#H7A-zSb33rvQgi=}6s4MAfVzmT9yD^`5Ta+KBUbTnOyw?UrMyOYE z&{r|sZRsn^-IgOf4G^#>Yq$4%d9Y@+b+9S)!yx7Q!M))Uu&T+Q7YPH-paNE#2lOY2 zDCZt9d@(F(%2=>~1tl^#7LGNuhGO0SUBZb}oUpsfCj?*p2JE@HGYc>< zxUi|dw-kUvL#s#9BI0*MS8ZWjRV0)Js&r-a+*9Q9qUIFAz=hHoZa8Ru3 zmEZ!_ZPlwfn^nNN-NNnvS{hoe4ArUCdQG>LhJa;gFBB=~H~jLTMDOemZigEQSYp$# zY~J?QuvmiHiwf*8DH_&45=M-YT&H2BCe?TiYa;Y!(>5JHvt6cPO{|TuChtAuQ)R4o zEA1|02`w*%N+Na+MpE#+zocXx@CaKabQ{awsKCd(A!t3ni-g3Hu4r{vpN!o_x+)~> z_u63XG<9;el9(sd4&|;kyJ*WgcT}%rZK@b<7kj0x_J&bivhOm%^h2Oh|ohGf&F@UtIwGA z?uBNY3HY;Pd!9pkEy$Dn;^nS(7UC-`_EN9{Kt$;P}5ghCwtcwIJ zj0)1QgeLUR7Q+(EC7Ko{5cnEaBMyrcj3Wleph|2tCv5kbjU?QNDZgjVau*S}pBR;< zi;Lo5pj|RTgR!7IG|tqc0+#AKauqtRHW61&-m^8;E%ro`Bam5=3;Rjw@7J4;vk z{&N@UN*&~W-&?N@`W3MOX=`&PVXhW?C3v-v z1S~c4DAy&!CBo7WAAAhD2v`zRixS9Y)Ucug){=@MV98Jo3zFwBO@XKHBpwwQhjodD z6_)C3ktAm}DAz_#2(1gEPaKxix{D~YIE&(J;4U(j!Bz(uON=>Wueg)0~mKcV=^)7lu9G0zJb`8Qx z*-gJchTKs!tbe_yp$H`C&%zcHcaCf~jl+_lh+x&Qq(dl{cRpXM;^e0lzqgDwz>u+O z9mcTN2|X4&C|S65-blo7ReiFUCCoDWCHUyt{DDQZqGgsHawRt3n9`LHiv_5Q+q^UA z3A`$w*}}?PZkQq3GHmLVkI)}`CAJp9&F|3ID?wL#V=8nEDvNq0Ea{~E%Y*k2urAwNJ=Z7tyMh13q)Q z|L9@jY!87a@JgJyXonz)m6_>nOVG(AEq&kP!(*=mbZ>aa5p)7MIu45;geCWk z!y@H0RlZdS4NVf}G@vNEB|qnhyns8uQ7E4tjU}e0#dn~?*d&4bgkLoe9_%pe^}rz4 zBVC>C+~}tU#|Bdi6?$=?=W`tAXm(lwz!UrRztW2WnAJ82~&W zjNw;uO9AUquYNa30+u+2_+|mC%$7|4MuV`HRMj9X!OL*ei-dR?VZswagxtdtkLpF2 z**Z{JUWRC<7_+APo1?j-LP%2XKI?Tt=^TnBAz4imK#2rz1&KRs)wJnljubA&(j#aOK*9OJGd=vHRm&IP~DR|PAaMde;d_$Lr z;>1ifrxDDB+a;=AiKD13{YJ|>vljLUSPu~V4gqUAhZ^@J(D8B*)@d{34^ic5^CU>( z;bm=NgYJoD4NIi_Mk-0X5bNBvRh7o|u&TSygodjG<}~0KZ?r$qQNxnkhhoX0bnYQz zc`Tam9!w>RJzz=TzAYXx>wyV(5UtRg>kV`(iyK3tu69NStaz2N3Lft#T`5#VAyKSq z+?K@3Nzil0uY0v5B*tFd?ajfJ3bV;lc^Y}Nc9IPNE1We?r%e;E?CO;mTDP!XnSAhp zNdLe8{=eL4+gtJ3VOS|_%EdQC!vc7!8rG78<Q0cef&eBEjX&^UnM~X7c6E{mt$@d(PS2xlZ9PAKGM2X!%yO2fKgcdvsz3 zyr}{)dNOE#W;fcx(4gIp@h~D-MOv;iJuWhhqhr}F@9igb z#UV9KRs3=mL+okV=!haC*gznUhFN7?7yuFG8WA=-#Nj8(+8iG}i^VOkyu@4}%6|1sH&FIvOL1*hV? zym7gS`ge2pej@?p3=14UA%>d#C+-hK>wjE8P{EIjl}+WBC`W$XO!&As-9+hP^}W^T zS*5YaIbDQVC)afP!>1Xh2q$1~}6j!0dQU%W4?GgT};EXRpzN z3c1OKZNKE8oQ_&e*3W+N}jLbiy?5Vk-d76LvVX&4!fcRiWdwb?1rF zH#%$iVOt%7Pk5>;D*om|Rs#mzjV!2QQ@)9`86D652?dyzg495NS#g_iNrcdom^#rU zDp$V@3+LDGyqRLtqInQ%!(eR&_iSN3|VUS|Lfyhn*^3$oDv{vii zPzNUKC6CiH3g;SciW#)~lorwR@rJG?N&L*!T?w^mw$@H*fx};^6$64@W7>U~gmvW& zios(;Hy?(UfqZbvd{Rn4Tz(e0n|~nMCDz=jW~)HD?*?5pAW0Au-64((NZ?QHj+%_+ z!0TZVC|;`;J5~J-rF<-SbaGh)7t@IW`+zLc{Uhx!Be^<3t^%spwb9aXJqe$XYRt!W z##4X^Mmn*|pmP*1_7hpttYi+~KWure=d<|9py6DxLdRRohNk$cpqcHtChly%fP+Z0 z)caVl2XV2>UxiPVH$RcNL?5eXraZd_6jc4%Wk~0D7ScA+w<59h*AO8e!f0r8o*DXD zZ2v3ti9#I3Mw!+8?%^an30;jGgEc-iEJ-yRz15W;{|I0lU*ZV;#VGFioWiZE&BsJ6 z>g48HR+sw&uZv z$p?-JYt+}W%z8{$?ktr6^Qqwu#sL(Mh0Ua*sL(%}@032ifeD5?WnIV z07c#VB_RB~`*KkBQT2$H(g_#c&&{sjxbCl=33L=%QY`WUl&ohwo_wwwXIfNbR&)H| z9!Vs5?hOc!-VN`OSK_OOJI+SB-W(Z5^%?0yjo5Z(bD9UiSXA-a9qCFwt9iY++EY8(~D#`xW|9N+!}!n0LZhzp0N*SQE-P@ihC919fkLOyJ`R{t85)+P*b1yqF$+*xQ|L?h z@xtRx+UciBaL}6}alo+GC)m#_N{A?0E5n`s>s7mGf6)nLlMMDN6hgPchF}sG&5KwK z{dIB@jng9%&rq(6!txO-x&wI;>;1rm@|D}`XqdJ7=pm_2XXW6VHEQN4`CPu2WrlIYzQ{h~AZje-i*D!{yd8pI6H+EC<%9t+ ztnI0iWqYh&Wm0_e?|ZL37R6hk6Fn)<=KsglRBhQ3{4IXIN<`F=%CB~Wyf9V!6CZbZ zP{+rOqxk{5cXv(_1xZoI?R21oHx*m{KcoS}c+-xb9G41@8qjuza$)7(q>(T++D+a= z(oXjXZ$O9N0nlXKxcyzZE!KPgZltwGY3KiwLmtv`HCtWHS;!X`zH#WM-a~X4{hH|D z=_~nAUL72QHT`ai_W(_t+$fNtZs8WGUk}AE!7DEJ1qY3=cB2kl(=IFXW z09@c5Wd@s?+zYrIy3nh(!(PHqhWJtM4PgnL2`Izm5yyD~tj$tAM5?V;Sp=t5j*!Gh zs(=(#)(Ae0dXEErc?%zL63S)EK3u#v%-YJy*#N$6^7K`*)uS~ro&eYKu*G|M&}@B* z97yNEn7*IrRV0jvlf6hrhM=umV7LaPJo7tB;SpOoc~img;Ij%e&RQCjZp!hf|;zkpgExjX+Z z27@Knmv&wv_w{&IaAtvsD>YK1#>d69U_xL2A?0EWR(W%`!pI0=AXvfREWllr@LG_4 zR6`LVKe7pm3q%BlAWe6A_~pJ~%ku9{8FJD|aRcS~D==0=-x!l6?KjG`Z7N$38hqfX zhQ0L&V6jcH0;^IEI_C)i2fScm06Ws6x-h_x^bxN9Bx#}tnS;OudgZU*e+#SOF?Z66 z^fdYZZZhWmwwZs5^S&KGf_ByTOOH;UoUNN!p=aNJ_Qs4R$Dpp_#hxMpVd$ zls+3UHzrJ+IjuIYv(mW3L%; ziv^YdVq4gJU+UC&NASxCutgGEVT57}>NUb9i7Z48jmQOqA_e9jcyc{WD`*zS&x*H< zV5&Xbj&G+B-lhNQ7su^U8e0cv<+Xm=gMGCK@ZR$IJ09#1(pakx_b6YHw`i_$U&z15 z%;I&R7Da>3a1OTVz?O;+Yqm&bY;*-%=~VF{j4+JnmyV6dLCV@N)%Y zXb8g@56FA74CH*Y0K`T<5+v2(ivj;|QLw??F+D7q{raQ&6i(n3K_apubm{&d=GDQx ztVpAeTjZLZXvIhh8l6h}>rERl>DU*lx!5^Jd<0VnRe&Zv!OB_p(>kPYh`*Gl+m-Z> zKda>?YRfvE)uVAWl5jkH#p#!7Ul`hfrl_z;>5x!0HEaF(opBtt|4eFnYA=OP@2rz; z<~tI-+Etd*&cVcV@PS~M0zIYmR%@f$EcZj$Fqw!iF+ zcvD+Q&?QcB^6(IZsQy!AB=?=Ez;ZxQs?WJH5(~7w?``pAzQ1<)PC><}!=;G&VqcQ~M-`s2)Pi142{1w)Bsm@wmYN&? zx1Ze`kn_eTv=oH87hDX9h=%;uKOyqR;Ai8(CY#6ZwnrYX2Zmk|o>YaJtXfN83I4%~ z(#$jYx{zKV?oFG*f&%jK?*?vH|GI1FH;PC#rEs_Xdg&%<1*5Gz|;?n{>Ks#SC6edEK zuM=n!)o##*8jxe(DY*~8k}s4sL=$(6ejHmHMwE*KMwgZBF*P*yw=C}!&1O7O}rbDFZVaoKs*)}{m{LD97<1& z#Ega}f^b-iJdA?U2N8=;RRNLHExzxq0a=ul;Hi*R(;yBdDEUwnB7+T)xsxlcqKJD( zy^e=fW5w!Bu{2y=>t}SS5zKpZl7h@-5z236el+3^a}Q=Wg%n!C#Z$dn6g}JV>mPnc zG=|FW_wdX#zU}-9z~|>9%FItI`R<4Lm7gMSRqvu@(J@(HOQc;~LZ8|>$s_mkA1g67 z<^ss=$FeqopE!RVM#$4ViX}dS6IwR&cz>diQTMz`3Zg^ldKu%`&$j ztpDig4+Y*cV8QxV_dsn)SLeSQhvb9$K~L~o z?@8+g-g~q}``P!0Wj1&-n#iCj%3}BhS!MiJcJqjSg6qnsqi0*@e)~QPgx~o4C*4^nGCV3j2h5cZ$0sG;Vlbw zXMX@vMTbQm*r7SQNyHO2=(|F8K_eP$r(f*RTmb;F=Wj(QYCOsCOl6iSeUsW9Z?2=D zuX7)(kG{pnB%8pW;IFbXu!vquQi>HJ8G-flKJ3qV~BzBhksXFaSFF@ayjYq%?d- z(mRNLxx}lugd4KODje-M= z)fQTe`Q_$D3G&C z^y7cHk`K8*-^hoEat5*+Mp*pLywSo#(v7^V5elB$ND5T4Up&;v8&>7k{m9G8H2|I= zEExCCQtfP^&NG5PYbl1DCfA%l^N<5cpJJ~I0xkXgZB4ZZX?M;B_DnJLaUXwtDaWq% z<#a%}obfAwI6jL{C=3vaaZmsp#O&dOadY`}`bChg%B{SGa=uMzB>dyJvfeEJ_qH<)oM5I*5cqc(8e`Z$zU{vhQG$OU$ESohzrtr+96Ie1wp?Eb1hYl%h z_miaO8X;BGtTgHv1a?%bLW$02{v#BOg)5X()qWOjT>*t=XwSF++~e10%G+?>%Es!E z9BJWuK}qSUNf}apSN;QetDT1870^paL@Cc?J&gO$dyslJ$NWxkX~iA*E?ap3kB@ ztm*#zdVLiCe{&U__SyKc*2Ocu#y#xVt?fmEJuNbWIGU$eIXPX+c3i*C#r6@MqztQ( z=M}$Dmv`pAALUh%BvtJ|4rdwQg+R!vIBe?WK605>Z3)OuGD?|IqqIcEW3>fjtO+gH zEJ9i}+8XBmvsVY>cw>P-)-6!Q(`ZFgkbUheLoQ7szhkHSD1fS!#V4wv*VkgNP-K3mq(f0)qWjWGZjWEiz`mpI!pjs!repHX#2fo_&*>0oxocJy$PQ%oVPG>zPr9bnD6rsfKgwz+nqUayrtnBc(#b{ z?_SuGq0U+tSdf3&t2_AiV2IWk@UsLspa+JvbQ#0%UF6qQZjk7+%(ZZ%o$3V1cQ!`g zaa~2qBJvW=kCS|nZnCw7a-7`q4zHPVOl9A&KvMpn;@ukZkk3f+2fYG7=f*ySr&KGD z%o;jn8vM^^&NX0~LVUQL&4sb538^cr{D+~;@? z6>sx-!en-?n{jNXH0`XVC8Y?lvt2n3yR>&@Ao?AC%-&LuD*7IcV}d3glQ zA!*xRO|Nxq*RvCPgnViZh<@f@xlbQN4%r90F!d`~v_CwMcRM~Qn^Z_y{&i3>oD&J4|+v1oz)WeA0zVM z>JP`ue1ka?)qFu?VDj?(up~ldp3je$6eSoaF%zBz-M+B;l)50&*W3QX#0es6j>1By z?$p6XGfS&AZwKDj$u1m5X1_mJ*31;Fi(=w@$GGZNK1cqZYJgmGCTN{X?4NZ`<9Z~B zsad*}Yr=k^DUo<__?K z&(~?cBjhz7ze>{iV3fOM&Gn!vn+%hQ%O1q`3@XdCseKms$6k{d#JjUc!UpIKmnEU9#$IHz>^tp(2Wc z@f4AN-@o4KkTSB@=&T2MMs8THk_&vR+IpQta>=eV!4P2ow`g#SVdaPR|BxRIb(c^ss!54UYCvo>pc}RV&>l1^yxIN~9R%N1$ zJP3yZAgv!UQ4lF#78PKp+R_JEBoPHG8Dc`@2oN|pZDk#-R=aJj-hhyQVSaSNgN6-rTZlwg1zf|1KIY<>4Tk~KZn7t zbtYx;$C#+$75+o{4`q6Kq@DmtO=$h-A_x{#2+VDa9As@ud;P}#d9}Rpy@s_I^3NUY z4sY>4NWp}Uh2e&`!c*4G8dPfGkn~dm&C}kmw;z3wEp#iW zbHqM~jqBW~K!C?&H4@k2#q@An@&<-UXTcINSS1j+Uq9!*3pqxXf=RpMlhx?t-(3B& zR6i;A4fzREZms4ugIa$7Lop_!c$wD27^Q6?lHY^oufF^Aj!9?r(}Hz3wPaOZ;Z9@= z4*7pgnE+t!LV4g6KNvQJd^x{+Syj zxyO?ih+wdL&&A7%LVd*WmuEqhH==y|a-!PYJfxvN0lbwNNi11|oVJp^?r1&V&76uB zX@f*N>3i|g<5Z>iW-Xi`^MI6W(*Dr*W5x9Tgslyf$_=9@K@){uRp<1+6mBi{%&sE0_e@0{)E$tV{*C5Pm9_iX`DbSf*NNdDY5 zY^B9Taqbm%RRt?jpzQ)wSLpk#d1&Xu1{H#UEm9x8{VnRL_+p$r4fK-IqEB?qX8q&ky2b3Z+zkq-M4#j|Hd%D6a5gm^`auW3+5*@ zokL%AkGMf1=0D}$7f*k+352>n^$hyAeqm_;@TYWy>qfIH*oqe%mA)sdFPG8@MA_D} zW)vQ_Zu8zvtlBX~$a6ZR+0K`O;1jY@-n_~MK1g^HK<0-O93(ovFu^0FP_GZo_pkRV zbKp-Y-9H=eGjb9GLi3rsM4l6rAz5)nO=8U3F1c9tRYpdP6VjPkmS46=^qzRym6@pJ zH~y?psa)ap4*grcBY=`J8gfN(1XQ~mQknSvNC<@|(@-P7jsrNk*^2ge0o~f6XczXI zFg>NB@0#>5zIDNbopS(1Z_tC>H|i(H2JuG-EBDtHDVQpD+{iBCg*wiMWizFB_!+5O zFsH-%4Aa;IR;o3L4vDbSpNkvlXdOvy=?G|kO4zd9O?8WcsL|F~Jw-9Ntvn*M%@eJt z3n7TN%Oh4QVdz13B7YO2LetjiOpwr<*Nys2hN8N?&Iz+~g1Dd&DkNkD* z*C|cAl?4{l1$-*$O&$C6tA&gEyMJ|=Q}xB8M2tGWVR-?y$* zD;S)L=dpW_;gU4K$hk$7eLMXx^J*-*wipktSZg&&3gZTBmpQ(git4D>MwlQGI-LMA zM-dFyn{)G>tZ(R4vUiMY8q?orQS2x`fKT%#N|y=CEIeDzMwHg!)mk z#8L*{YtJSF>@6deg-!#Qx!0E;DKRYS{FZVc4!GIZ<7GemJDNQ1d0P$ktdyA_Ju+nX z9}SqZyxODu=gxuZG}pV0O3&Q)FphD%TiPQgJ8OwZ56YXls4*XCYs&ROd?K5rwvbQx zJHLoFx5l6+3z`p0_Em3Yt!}j!F5{j8NjRpkMbT;-pB9+FbSzqb4-9&|SS^?_z5caM z6enx?r4SN)I}V|SW?NrJ>P6~eavsJXYBJRo%;{dB&AQJfzE)sfwB*9*@_o}sbf|fl zG<2!~UDC)e-7dlX9~UrNPSIu?yrOF>ima|;-;#XerMv&9EggL%2h&KJiDjJHjlPIH zSbjWe+__T8sk2wtgB{``U}FrN%eB^XM|NGSlN#p2`!=ls=Z6~h%iQy$-WR>+23}1z zZb^qAaVM6D)b1kA&Pl>7q~emT%6i5S@5!Xq`GdBUJhHbD%snOEgE{u@&!Dp%6|w5K zzwtuZj+Gg~oMiKT*Gp@3;k#VNo(fGw?D^EXwDoprCRso^jVa@>2jiFNuR$scaxLEY zo$3frd*VQ8SkM%5-St8H5k!u4SSPppCyXdlG)ZW2#=oG;s{LukWVZ_q_ocL9(mZIW zV(}-%a%nr;X=CocOL^wLb;d4UQxG_S}dA#u90kRuB%AtU(QXU`oy& zy2qN`TEq&GQ4Ky3eRXS6ge4V3_^UafA=*=cne-h&Uy%CiQ1?2Ocrgq_-g>O# z_Os!sjbNnXxNH*a->e_AH-qx|U~#fMhX=V+3>=oPD&&n_@*s&Qo6#+V;}X{3&Wnu{ z)v#q6?;{D`%MA%!7=5)yqZ5CN47IO{Lmu43-5gW^3!AYQF0^IUWFNAssa7Pwy$6t9 zab%{ic31)%T*jAMC?}|{WOGgh@mlXZ25^v28}rO;y?{eyZf&{7kz zD6}bQ4S~}+(W~npi=XHA$B3@&Du;omfQB|QH0u!2p~4(d_p6p~S%!GkGMh$v6#qIk zQr0!mvk?7+*fnXqm#5>?{*O;p zMJar7whKts?fPI^7?wFMnl$L+1CrgZ)i-IVzPp3WjkC<$t5A_I`KzUawwI1M8tlHI z*nl5@#)=gib){kbY`%40Y^WF_GxFQ!VAlTH(kdrddXE1oCR~i(&5eqOlF|8^^+Y#Z zi;V)B_`U#WRq_*^7CAxV{$ooCO>seTXSs?Rl&5tusLSZL_+LZ`aGygBN@5xuS&UU& zTSUXsKx^A-(T)xaqY>dmiknIA&0fXx8fuIQM#`EJBkFwZuQ`@Wt2?N7FEtm$0>)3q zo$SYR$I#xWZQLC1N7zvwXIvFqx!hCDBG$4$;+`q;{w`Bii(Zqbj~Qq!)rV<8bGkQ` zzv}eEKY346Bj-X=B!~OyPy+E4J{=z>?8h$FRUFC$Bt`?;yH9)F9JtEc#30FV$=$U=uxi(W^@`J44HlP3oEb!`YaT&r^(WjIluqG=;nR1yi>2mewUiG zVxIhlBNJwjtZG|=4W>4MvK&;CJPpg+X|U1i=Oq-QxsZGR>kQqZ057k{3Hy#s3OOWM zHwENF4hj~a_n_S^8)8J_2l(^=33kItPLtbOhov+W9w6H;&3C`PET&vI+UD3~u-7m? zet&V$&>pY`5nk+h+TL$@W;`!RhK${hFO!NPMvFopTCP<5G}!%e+n;7{KvsyFQ4Y>X zGJlxUa#g!Q!FnBgV6A>M|OF^EShTm@?W0o2Onnqao9_Ii|DDc>7-tN!{n6 zQpDqFEaGzuy60k9a{RoevscclWu1y0-Ch{*o-8WE*n?ax&=WH_>DHKt*xT(G>SU`9 zTQY4P-?b+<#>P4Wudr?qc)M|0<$~-dbtU4SGJh^0N>*;El*`_D$$Rpbm&`h`bC}C+gTKRq4$JsG@$-YDr_|{!Z z9FGlKoA!ZbIVN*ft~NLOF{ z@Xw0-0kE3Wo`Z`3I+U+$(k@5d;ixE{p z*M`8Z(Tx`OV`8|6?gc-aPm}{BI<8D))wR}-j;z*OaY1;puw<{eHzS@XY8>^O%#&`LT8Npk?m*X;I)pa(Sy}b{VVQ%VZdlZFv z=__9}Z2F!)S2<}lG7=11&2%-duI=nLlF2?#;$?CVNDjp%d_i4oYDFfKoX|D?3T(%vrDf+*%UU% z^A6i^z_`O_Yf()qM)dQBrt+9MkE>|Y7As82GSB)bPG{qMcvcxZJ|189 zN8MJD_=Qs?Wg@eIIPIw1dI@shvN}bWF>`SQpBu3~@yCpW8N1d5m~LEdNFv8rDrn$7 z3Fgo^9K`aLS%Zs8js}k!%d)RIa!P-WL$$Ph$B&|NVwKAxOBMNR5dDpILTQcxnRvn^g;l%BedQ2%a{X!O z%=mhAR$l`D;DjJH*4viTF{J(`JAE75%N3sU;Ma<@WA zV0a%P3Yh4dZd~BR)l#X4lwLfywm0#v)*ag-F+*_^ZBwO|Iksb7_$=^Sw>nyKV z@(^1vjE?s-pE5kV>za=Ptw=T}PnpM<%%*)ibZj`>l&iaGy) z#x)hVg|PlgL(OO5%mARw+`4W>{U)uffd$Qb9K=o3cZHbnb_Mf}$E%5R&bj%z@_GmM z(|7OD%nX{$DC;M;#4=Q86dYu1&8kH)LvJT-M*lKk6(w8BNk&bUfa_3VsJ_zrWhrd8 z9iV&t3&e+f%x`$0_aNtbUR-0paoR%=u>8E`th_ufG~)3-ca8Qo8%Q~M+Hm~7_6|G4 z`x}kNuj&#|R-cL8l2yH7DbawY$%J;R*lS!84d|~59-`&~%{cE+{4l#r*XVl0#yD~> zalrFweT~)S03S-rSDEu8IkpY8uZyAfNx~6kH7D}L|LBV& zdhxP3?-wfEQSmbR&|O>k7r5GY*qetyaH;|#KOx6G`$v~bRgI`dnV@+k5x zc(-}PyX#O1LgM)T0oz888oqw`gN3m@?RK;&rEdLtN1vkI^n)!U+p&Qc#Ba+gfcbI! zs-t!4+QVBEJ8){;On)B~ViDORpO}!Oz)@~5AR9S5Y-f*YaNE%03gW<-*Jo)+)L32f zJ6YPlHjk-v_VI7HJ^lPs%RZ?|d+<(v`O#VDBM`P-r*Q?eaB}eZHz7e)ZcN?>fH{0s z@@Mm|@Pu%a*N`q<@nCIwM%L-CK1oxh<`ER&&gcgs@~6RJl3Q`krQvfu*rT@ul4j(fQB9t8J9();Th3n3REJC*h7SIA zl3e0+e9QlS_nMQ2mn+S5$bWyA!l~(R|z9;6-_X@pf zVQcHZmlA`gDejm2=~0LoII}STiX6K(tL$@8W~7{hz3xfyCjFV1@9>#k_xcNs07!yw zxy~|t`%b&K4K#t2hSr31!rRaT3gm2!D&D7Uf0_RA94l7vB4e)Wc1Z^9B$)78+DLn< z=?la&ul@;3Mu|pc^^2g)BbzqsZjAL1xkKrVG7RIuQK9OBpAqE_X z#-919p2M0GL-cBB4psOMq+wr~4<)J+qA9y;fif(POK8mK_VIcHg}~rEyJKOl%G%*n zOvOp4_-#&iy3)SDue&F~*n@jUGV>?B%r8-&6OEnLXcz4r{R1)t_CNhfma%GwH)Z79 zMH(mSi-4*i_29TAo#&$k|5$k0*iLuc&0G{%ywR82cz$<-D5c)54=^@k)BfT z`fwjZAmq)lS4{z1IY=hFi?QL)V2@!ar9gZ*(Nhq<-TKOX#<0G>vPcqSx)t3cSp-sgqYW6;i zaeHL5l0trLcde!m@NH%W8@_D+xn9K!cmKx)`dE+@E(ACebn2Du3pbI31(cn)_ldYoa|yqJ(^p-svbgj#_=T zkYY{8qze1R7qIC5vufx;M+_5_W{hVbGQ@b zE_A|%1_WgXc?eWmL~pK7wEpu7YxMCbNF!8aW3wfX zX7Y#PI%eEnA4eaBwvrO0hsxiHkCBajnV60L9b zvbNrg-Mh7lWnIwYRCw+%pLHx+A9SRPBqDVD_wkNy-`pwhKCD4l6bh@_JE>P1 zug2}LE9YS;)0lhTa^J5>MJM^``N>6fyFotMG_mE)MKO?e?D+BRPW=!fWK5VN$V)O^ zvg5Yc=FFL9-0D*u&{Yxf;(c%WM@#SXxl1k#E&FdJ+X>gqiNSl6*AkL%rxrf(?hG9T z%0%xm$+tR-kM`cBtxaieVzss6MY)^2+b0|F#?Y&P47wq`?H1wXkV@R6zM^i znzHG+U`*Wef_o!&*vSr=|A8%xj1Ln0MhH5|*FqswdV~U3{k>O26B&9q05* zq&=#e@42NMI&X>vSUg4 z4N%{th&9=DFU9`Ld^zalgw-g1t>(*EvMSqdp^Sxz0Y~RDP=i9%X2HHSTlU9?FhdMG}_+e7CR+)Ixc24_b8-llLz-_tAPHL%ZE1X*WEX9$xg?PK0z;@t{asT zTSl6RcR@zaZCTFu2A;)>cEcHUseAR0YSZrbuf;wtLwVCLb#1K7ccmkDy;z=eD9Yy! z%9{(^(>;17FP`*-6%%aA)5b3^?jsZ^6TFAt1T1_Vg77;SB-lPKRi?Rx4^EXf?97h{ z(MhzvvR^*=6gHD<$=Z`TVGiK+0FS0QtdHIMK~q^MIF&UZeHZX#;=&h?GsXELwL%SZ9i(QR$UmP0Kif4h^C?!C4H_7LvnFPl4-Liw(< zMF?JJX)JOt4@j`}dvuW)L92F1v)TCu3Z_9TIH_Am%Q-w$F2SIODZ+HSa*B@+Q{RKR zQoi5(S@@IX*WZ_f0hepZ+fS#=cXG8>5aQ^K(!7w;iTwE`IQ%>-$#7sk61<{Z*pf_gmh^Mmx{7r>EOTsntGG zGTo<4!NE#G##1<`0GvLb;6ptO%|Spu9$DyDKi+A(%xIctRpbbXvS`;i2Lg z%UM&bc%Itrgw{|rvJRxG8<4Yf@t~-SC;NGd(laRdVz`NOV{BL=3}p385E3um%%%+g zgtLf0{tcghoF;Mrd^e_>4{chB)=W}3;)ah4TPtTAqVx#eoicqhSUy|MFBd6;`GB}Q> zb?V$0a`uQYFdK!61h~x4*)3<4sMqXN7#2}!sUZi%=k42{2+B$p>c|Q`NLLGOk3{2B zy&prRO6Vc8-~2->UaT;J+bTSR-B&X8Td6H61ZQG5`emi;Fltg2Ux2r^;9?K4)(zr%mnby57d0fA0jwA$WMy+|guIx`F3_jG z!%OBc_%?;yQL+8i{JfRUHME>}*Epb^|9+PQ_~G_(ox7jbZc1?(TFzy5-NE}VemmPv zN=1IX_z`Hq2P-4HN|_p-2bC4AOA!^Cus7bOH+HnQUzs&1xiDR*Qze_4EPenP6!SL9 zBm8w7fQ5Fz+J?dDeWLGx?*bKDkvjC09((N~Sxv=y4h+c(zH{|rb)x;TY|Hz+bz}(Z zCI82!i1CD;#x{qf#@56E29hYzA?@48rT__Q6Ok4k0#kJ!3LDXWT^rxO_Z-JF6pNdK z5#(b;!ky$l@)px-S>Ad)8%aRdV-sUvoMx*9s(-Zz<$eEs?D& z3)wARU{FrI49L|Q;rHVyjdV->Ky4Te3rg@lNtKf9l*{+VsLS=-)&7bYk^!HJbxU`&L)nomla`B_IN72Xu$%_ve3x$Bw z>8pC0Ij?6MVbZJ9#ty|yR41CojyJD+_xE#=+r6rk(kgbZ5Npc2wW4zhW{*&%$FVzz z`E%*x@tO>)=D&pghbOio?#%T8-G)LB2fwwoC;x)*pN-O;1F^G`I=>0-`IA!wD&&gG zD$E*e9DZn~wJeIje!O;_(!Pz;rh|-;_W$#-`OD<3sKr^o3*L?43;d*kj{t z#(=(wg@Btry)IVwV$Ea~gA?h`M%2 zVeQHM$dLVVD(mX&H;o2gmd~}XIJmSgirJUXiN6;{h4H0iQyJG52~-5w2NVLVo$LJP zTFjG2mTR>c70wVbd&Mb{>u?R$Qh*Rw2|n&Ct?nl9=XUEjs9moK`*;|F&~-mDU7uVA|Ih%K zC_nB(qus-*Al8UI1I((3_6@h~i}b=nA4z0BUYsQ}`CLv0n@$O{fS zx+iB?i55xZGA?YGX@6CFU_4h=^1Lk`u`R5VLV(Ak}c)n)Z8IYxOm*pDLthhIUJ z#TC49Q_JiN#VZQ=Bg}a%)N4W3bgRsLtXxmvm+u_{GQFlToZZZubQotF@~Y`_*Vvys zPPPVUve7)8c(uA^Qhxpo>~K|$N?+v8O_3?WC&tNM2^tL`&CCRIw<;*Mka)&9J6gs@ z66WzveA!onN=3^P?^VPQe_D1yN)@8NvD@8q-=B`J3Q5g2L?-aB$rrq31nk>+m22qY zo5|-dKL*)5h)qkl$wL9(8|erAbyGSi1q&xDtoh*MPVG}2^tn5_bs~VLoN?%ke833k zQ^&blX?wfr8KV_hLn&+Y@!q zb>rCSCVNYs#-<-mO!xhEC#HX=U3Qa|cLJ5e8%(si?kbSADummtZZ{-_$7JWyI_`Djh9GKQS_=46hi>Z8>6__qt~734Lz zC0=N0`Qpb;8!RSY&~E&yp45TYDY;Rarz2obF#~S)e05R%tZ;bXMoPmiQaakfB6a_G zDp29#9j}BU@xUeqAm&Kkz4bm7lZk{6d+Cr8l|nE5<&?+or>pSWNUBzsOdH(Yz$WDr z48`vEnzrNL_ihk#&F8C^jaMmKf_p+B?7O+OD;_T%OghTpR<7ygb3q0h`Rtt|y&6&* zWEHG?xGZC0w}89Oi8KXTitLk68+NNcStz#OUhK&lWMmCLq|@KG{?$&)Dn`#*Hd;*h zXpp{`*Q2|Kl2V_7_FKio73j40swmvyzg4k}{9fkn*RNk3JZCn` zBz?YEv8AVlEe-r3KC7OyU)1jU2;w8({8-mVmmyH`_@wWh)#JQ&U;KQ25b$t2cK-9m zq5-h~oWOxB0Q6Vpu5I?m$I+^2hMGm@o!MtV{yzF93g?LIwHkeRBTw_EUiaB$({}vR znR1WPBHmXv@`AHEOa4VjKE7;0$tu&NVGj;+Dt5t;KT{7_!<+3%*RCDWP7z?uvYL*Q zWV~HxU#UoJI1cb)U$<&rZ>Q)V_r6N#xo_6`{%vwj>T<5+$7nU3G}~;9cOO~HvxR%0 z>HulK>+L=;(+`~N^4Bbw?8)heGa-Gw$E&Beu2S*t_M|BdVY3Nh2Cc6OqV^%{Z`ltf z@woyw0=r7=W3G7WKVmPd<&f&S#8&kHW9mo2$47tRC~>Pn!5%>r{!)AKQgy&(y7D zb&nKl=6ljVb^JauB42E$pkct=AMBCz`SO1N)Icl0$KQx7x2qq~=@r{G(;Va7(mJlr zwdZ|P-aGLclWgO#)`oJ}hWhlC_gnSc^vb6}(|vTK{OfUyiX<>&i__=esBx`)&1YOL(j|{$*PW`y1OoMNvufZRQ00{5F-tS>JEpL{|-LipDxms$JtG8)8rVmW7!**itDCj@2|+ z$cF&fII<3yhv~7RZbFypSOY5zn0>n-ENg|9|M8Iu5IXeF|D$4D#r{;ZpoWJWb&E0O>Q5R!L%k$?ZAnv zS2kKMVU05RW3OuHbBh^w6M67}g7km>``>BiKi|HN+jBZS$G^-6pzZgA-}?6Uv$X&9 zNr{TeSWHcexq`?C2<^60q2P4t`p*S7iyvPV&g^#1PjAVz;;@!{rADbrt|Z&-VF{=K z+St0rR?4;8&Gy%}o$DN9rEiP-?MYvHXR=29mD#^X+TJ2I`cS37-WkOQqBHT<+If3X zvfkTYOA6gcmzoV~noYniZD2U(w$WIN0c;;`*HfMZA6ASA%ZlYuTR#;#9GAH>E@5kWE{cuAqG_7z@o{qDK)egS;+< zZ&$C{iL8atB;66BwLw>}I>lbq64v|-^8# zy-;tqOZ#7+YqU`z=I9j{v+SpQrmsz$+Pr_={x0l)Z-2kUlGo>}&s+BYU`;Rkb7fYe z%62o`IX}0bYRL7fG!@Hv+SYAdH0$x7hx2_`H_}S3owVB3yY0=tpSWqCg*N=XZRe@A zvfI0MWE-oss8qY{@ALk&|J3cCS)x4urr|=f+kQE&?>)=sZk?=Y`}ItG`gPHHzbtvF zt&{b$HkPdWp7|AmeaBL#lftn=8&c5075;2m+NwEp3fBlX1i9nZfQm z-_E=T3LH+T1MW(86MfaIMxC#R>CED*(^g?PI1TC*dWC3Rpz*XZ?YHjLqh39D(8enQ zYZJFp`8)=S+~AWJhc#t#iaA=gC*A6}{ZkUz{r`lS&GuU#Ie&hg_Q=Ay{hWDTi$Z1g zXSUz|tjbr)$7+eBW<1v{;7r@C^{~Vd_10JgZ1qzttwMBdtH(CHYv;#;nXuhntj}6r zO4%m33bCgm6R6)`BPpOPPBIjXa+pTNj;z(WeAmNjSv!7%I|m00h{l_wzUGo?H)z*c zumm?I42wf6_tP+go*DvptIhrR{@0lGA_=dQhtCLDT;4@9$Jpi@qut!$?I?Nd$7xhrhHyiHprZj+-)PSkZ(`<3(isI@KIX<7en-G0@$ zY_|1lvz@ZrQ$5=5be{WSXtJGRauQ;}aRjmZr<5!qRx{QgB}x_Kc{>f`=bVG&38V>7iZ;QXmdawGy^7 z*GS>Ncoiiapcx)%gvn{Qk7g%wKc+efocj!(4p({kp!Zbvk#Oe%k+a+COPT`SfYl zo62@G`(J)${NLpFt5goV@|FG5Q?)nloCr51;^wx2KAMR>K^+m^G`wzF|q z+wD2w{21^@xBXt?u+nTjz3*fvZRp48QgGKP&}*yZt#}vMIio2_`2szoph2819IVNH zovgRD<1ZMl{N`I;cU|dZ&|uMFizQ=8s8s9l(~TChB%1d$)MpCFmHl8iKB2ua6|2mb z9*9k-cvaS*=fli7{_N0m}HSI3Im=|3?I`U&FjOtZ4=}Ow%){?Q^SF>Au@ZdIBV_RgJ5FfVZ=Dpuuruds}S@-1k>*lD|KV^x{S)y## z{b{p5ZT7!@hc2_fls*$%bl$&2mq;%CYsDow@1L&u&30}7_o8B~`%{uC&-Y<}$?o3I zdVastp{%myCT}Hc8=-biubch*dMwrNt5NOKyxY>+u72K#)P1|!wZlVO{VB+=RBhX7 zX07?$mD*oN*ZsR~-E1!_qlvu3rB2)Px>T>iedQv*X4c!!sQ`5O zNy+`iCz;PF7hkXPn)9_5`s;JE{Zg!Eug>da&7MsFrPRX`%g%h}?qEM3sBfo)qnjcusfYNztw&jDhkdZv? z2%$n~CvslD3K80SMn!1HV7$c#4;+{yu#N#9LqE^)R12am!sZ)=yj|DVgb!$X;DKrTwn8BWJm;WsSpHem^nO zPUdzsDTcfKQrZ5==@N%kZrk&olw?zqvewR0jp?mny`r&FD#vQQKSkGH!<-bW<>;!? z&N-4rC;eGd>}wOBCEM*xOxCyluzYXhvogPFD0A%9tG<8U8(n*VLrm7Zo#YUcwY-Ze z=lCp{WF=Yjdhnn zN{4^ZQoIb>;ooQBFX#OSv)^g=qk;ab@>mSx3Ww331pF*Cgn(C^ax++e`yVoD&#%ke zYe1E%QCFwpbKW`)Cm1c*3(7F&P+JQ^(|HA)_$CMj zmIXnc$Wd%_oEmd`!C4rfY`vtMOqAA>O1VNcnW(ru_`HC5qeB*lxjd+Kzu;l%?TL5G z^v5zL{m$&{_Q=VE>X=$K%izW=%{T8K<D(E;6P;RGuRA6KCp& zLEvH8Q3WTW1p^x=PePsv#eCq0n2M)qV zxL30|X!UPp%)tPiGWG7MZfCDH{s@kcdjbH#cIoC)%9l${whgj8EG47OgGzpuYF!4_ z+2&;c&BOZXcUe5HlNGX8z)@invNki>1>#pPTx?H*)s5*KQ%bd24Js}fK*?(cw|16> z-B^)M=-JW=T9yZ9_*wTy-Po9e3qK6(s#}Lx<}9)1^wEz+XNh~J7N)!61LYRR3<$Hh zwRTgp9@kZ6T|s_9W+pW}9s3C~GCqu9aPWai_ii_1^RLNX(No#0!oauB5=A_A?XVMrgq_CbMl8z-L?3MktA(G2HDbO-R{TDOH-{5DqiTfcWSjzpNwu$7c z)>7IJh3(7zWWRLbe&lcdOM2apx3FegG%q)1%|Gqd0q_6-5CBO;K~z-nTk3!08e6*G zrENlH)BJH#V0bn2Q|%1bdCEA{a;Ck;JY=lLqoQx4C z3-WSkA!Pjy2tDA~PeBGPpN7$wKD#iHL0K4v@zD+<@_ok2$t3Dz37f^U3+3|)f8II# zs)5gF^?>-*3m1*^AZO!2Amr7s=)nJwPHqBFofg9|ghZCifaA^+a6;nq4%DSc2EWjdbx^mhlyz#h-K z%M-7EoHXbgv(G<$ zV;*sTi`(Va7Xjs3*W<+g>GM92#+$FZ)EBp||0EUdA)OPoBAW@hZG8XXT#DfU3I|q6 z_E|0KCMi4ZS|$RY$pvW27zdrSAvYBwlGEw{ijiE8cW9|Lme)tFnAs2pFPM7MWE~oZ zu8RBg_6<3IdGRDCYr$CW(y}7VEb9c)IXAFR-~&IAveTO~ zea8&SNE3J|Z$(|YsM*nhDNhb4demd~>U410eU}*%W_JucF>G_x`0Ka6VS5K00QshK z%#npFZd?l^)Nd;7OMzH3sQ2;?&n06IK`2`^GJe@VZcLeIvD+v=+XC)x2a_$q zV%;vi)*#ot9w!#upNlvDEWEzBb^VTq)naU!N?V@a#5pgK_yOmn%Ny4UUn^ykW8m3X zPIf#jP@JsryaMXvXZidcv4WyZa8<=3VwM&nuxzIi*U2(Lq1jlW#kA#*`BR1t8JB_Q zUd7KVM4#?NQk<+ljD3=ifAN^rNb=u>dP%&hTTQ|rL8B`BaCa0GfVaV3paMCI*7yn& zc&^sUrP%lalwt>SfN2y%bZOwuV$B>c+%~@8hI{6<9oz5s0W-o}7A)@6^vBh?OUO5L z4QnCKMjTK9V;CHKY|_2P&zRjYaR0F4gSLCjft8`I0mneTsgF5+DAu;UeZs6LeOcqi z#v8CTu)?qZq)gRcZvkfzz~?6X3CiEN%lmxjla+M~`eUAv+WH2>nHE!xxqjXr3;pr- znDomp^W*Ii=4MnQK{&r*pt}dz;^C#Dk6>5ozw8AmVT#|CX~Ey%=~{r-E@1urM06;xfFdznkj* zik~&1s~0QTSTLr4NHyMkzlF@4e0o&NK=wstZI+Hd6=v;`M~5A**S^RP zPiL=SIE-#sd+WHT8I2noUyp-y#;PZKg%Mwd;^)@+i~zxbm|h7f*7`6C`o=cCZ?Sz3OP9D(ZMVZmvsgW(B=K*c&)^0AEw zDG$qjB%-+5Q8I@JNm)L-&~ma4G*7W4$gM zc{`;2x7Fb*W+pFY!(vj^Ny#A^1Z{dUZX4=cWk*Me(f#oV{N zj5xHu@t6p?n7lG*eevvaWS0j{FNbPbQg-PabcLJRi)Y#cfU>R+GOGfiHuQV7uA73( z49_cF5=?gx^5Ik~qF2%J-lZk;3qU^-7B#q-B|j>8*ACUj>fbV85yuCkf1l9aH};GZ zEQ-d=5BbP&UqH)BfkWNT;l6WTH~A^tkCL}9?rHdq4`PvXmQF1<*b7Ok{_Td7R@J|U zFzbgYrG2a`>g=mxJawi1c|^s~EW)MU;_+uzoS|ZbF@scoSLr=<|8X$eC}$6dk^AR$99Z;2Mpk}~*MbWo73+4X7$C~W zNh+IobI+H|#-WVWvooHB2M#e)%*dYWghiGXyES4$AM7~B#NIDd*{Y|Ll1i(GA3is(vL9(Dtkqhav}J>9@k=PaFCVX%94j+xi9-LNqfS#tM;3BwpyYCq$lOC4o*9>lOq z2Pte*t{a-gX!fet_~%x9Xk>d;)ZFZelkNPN)HUW{yw)~vnEK4;z#|`U8yla%RRc@> z9tzj+s+5KWM<79-{Xsl@PYZkxoog|(egAZ^Hr>{D<6UPHOvdM5UB&%Q=95&ve=t}1 zC}}fgo!m5289O}yE+bW%d2Vl#pN~aHBny*?bC>zD`K; z-*4e$S$rikD*{fx`HQe*A^yVUb(b5yI%wCe!qRE>-C-^p^xoFPCXxE*4qoyG4XdNd zUwwGNtFt+v-oXhbrrx!~jOD9;HrP367<0fCPnc>4rv5Ppmu@zD1vk$GG^uoBmP^`Tn0>jswels59QaDT9-t+K;Y)Nq+mz ze(#Nw`#r-e)obJ*H=G6k6x$YS478UT>k?KRp|#d@!bzdO9akoq7RMpRvO9^RFgJ;F zm8G&gEUZ?}VjdQiI9ZU$=V?tr(eIN_E=#3lGhJVq-X)+C8D#%q#!q=zZU>UAC~MA7 zwvJHpup-&F%#*UuD2$(7Xs4F&u$=G7efjD8(L2IttP)O^`R^x#^%E5Cmxb>3xVtvi z7;cjbq-q*6p@%X+$!o-(VH61$ljD^zKA{|NB}wEVP~la5b9U7~1zZ96a&#@z?_0~I ztMJS;lfhOP+-2d=d}d5iVRH+!-WXpm3L0Ss&woabd-aC*dKJ*{BKPhvY&oqjdS%CL zZsEopa|JcWoH4cPgy%kz+QZJD35ssT@Bet+*cbu+KmYlU`B%jJYMH>@lV@b!Hti2B zg1Y6`{dR9rNk^KXD(rZf?2p`X+nAC?!J=Ln+uQJMe0Ccc(B(2bp{gFB*r4E=h@cz9lLa*4J2;fG-d?+u=IUKY-y!ZQk^n9d)=ySz-hix$E*4y8VCvRq! zx+NKGMSgAl*5m1e;vW;Z=aUibG^v!bVmGGTJNQZZaO!Gk^P%yF^LmN2VAwEX->^%M z!pZm_Azt;G(y>*SwUeg=x}|hu<5$5wg^llQvoa(yP$E-VM$b=CGR2%7%GH^28G&Ly z*eXSNo)l)~>v?vCZLSB%nW8=R{$KRFGDUfr{Lj6>B^8ow3}+}mj%m+ae`$NwZ`nv0 z+dP9t_v`r<{1CWZSs$L-|JC&Zzar3Dcd}3VqrSLuNtgpmF{$D<{OHc4g^+nzqDNN9 zl5M7P76?ujd&6h1{ERaEEU?)tjB93rkWntO)u#IG?d`sR(b zkLaKI0)Nb&Njzzo1Wm7638(?T22_6aZB^axAH|rv{*E`FISQU}OKz~+md}{i-1>%9 zYdrRzcCqcKQc@bnFrdq6u$-C!MN>W;P!S$A_`vwHmecBCf%=DCdK6Bc6GpOEgGbB2 z$m0Xd?DnfJdp0(D!3-?-SK$!Av5z^_@ck(W*%1d-DBQ%ti9`g1$9p^j*g6z zGfKE!vX#M6Ul(OJ)GCp={gE5$8Qs2{{WQu1kjaXq{jx{PrY?RQnwbBMVX5C|%Jfu! zMjluetOp&h8E3d$8j57UaAj^FlMZyl_4yvlb<^fd(t5i8QT_&n&Oc`Vddg}CvN!dDVINnnU(z8tN-+U%Tn)UKfqTe+7@lZn7wU= zRermd%bVPzUny1o>cj8^N&WMP3azQJ5zVRSwf{N)%-P%=cRYLbgsD6-4U9P$zSKW* z_pbALclK)Iufj60j99EUq&Y7@br};GW4YZzB14`J1w!`sy?z@M{jqF$SZP049#)X4 zDO0y>d06Fb&Yp3|{<=!{drnNoPO90DWtq==S%2o&CAVIn7;}vpTc%=^A=_kQsT6w` z4-3)v$hg(va;$g)tbkxrhEpx{}^n*(!$*dW({H_H* z*28i#xT;mIA5tC`M0K*x%cRp*Q8TMd#PzBGk~&%B*jQ*~VJwLaO$)5uM^DHdgxXkg z+0r^$$2FU)-_^#VxHeWbA3@?ZfZN~v3j>H?JY&*n!0^PTyb&Q|7%dq%0u^3&fg14F zK=Uu^dBKaXmN7Gf{xv)q&#!uKh5ciucFt$sw>E}v`|ZZ!PAweo(WzAHm<*M%3Hp=6 zh^{Fgj)l{#iY`oLuiD1X+1%rl<=Lwnhhc5(d#9b+(Cv{Y&keOHt^O3)*!Uv!*1#eF zEXlmB?WYW3MaucQROv{4a%WqdnBW6N+hE%%Ss6g;FiQXc5CBO;K~&q@@NM=2y{P+{ zhgGp{cwQ~mm~BUDhnc6#o&2M+LOlG87}qt!T)agwX`PF;Y3X`c-OeEXLM`MVgp8_A z*5Ts{Gc~gyl7SO8-9e27cLbS-ck=18OS}y=>tu;sp5GxS2fdVgGG@h0v0)+=7DLs> zvd<`Vzf?sYpE{UM%?*lJ#@WI5Z0OcBZ<5DboUF%(w)pE5t&Lxdw{A|B9s=dT)=h^N zY@u=YwswUJZ`d%ap2qZ2Rvf`pUIs?h&8Gjlx1(<(UUdyV8(@vk$nn%wd+YgQVdR&C zFlP1G$b$pS?6$L48-E*zy?b^fjIxZLIih4)&nQ>_=sJ8N8A~#hWk}?Rg3@ZCB;#?A z=od0vd??*dO&L7T#mKT>j&ELRKY6__JCz&1YJ*8nX1oJjucF#mRtur$VR42Fsy7WJUSpX2-Y;W9!eJS-Vd%c1PtS}) z+P2Yv1%r5SXbVgL8yla%E1^h_98=Z77EPz7V|q%rY;(#MWUppzm|a2MuT!s9GZ{0( zbF{u4R5#_x0Tt#-WllvGX2al#k%=#2)(#6zgXhl`gW0R0p<#ODTPIqYRJyV8`=M)K zF$iMDL}%ME(!{7ZWd#m=>eS9_H_ie$trR--;>5_4=QaL)nNu728O8c`tPwg{S0Z!f zOPOm{HMGVNkgK$H*A=Wj{Ju~3Co+oZV&&I`x&_V<^*9py4-21sJ5TbmQYC0rrc3Tz zDb|&Nj6fXP)9oStNf#hDs?A^FOl>R}+g4yHJqKs4c#e<-)1HZtw{Xqa?~Pl+W@&9K z$LqHrPsVUE_M+K!**rI~TNbW6A>1%y$_5%%N?CCb!>KFr&m$_drrxcGiC1H(HyA}< z9L~lVL2Cjy%)NRjbw$y?J;M%0^NmE(t+NG9D&5%l{WzG<0uZ<@fQgK;oNpmpuL^{$ z`Vq?Ys`UMHttM0=Ln1>Yla>t+%UGG&hTP{sW?yK?RNIDmSfyTsgq^C0CTN(GbN6OcU1; zi?{=9!|ZL(s2L-(o%Bou%+f|Ed949;yjB8pfQ^klJRd*r9ubAH*Uhfi=81_duy958 z>V_G!3rg-fp+{-5wv!<=JI!Hrv!3iV#a*dCbJ`zvku~VC?3FA!SQ!?FvsX`|Z0@~P zYaDL1!Ec>Kbp=uE1lZ=yEyqUvO&vWXWX|73s8>vxDr%`7Y&Ks})b zi7Y}y^RUprjj>aN`|KW)xTQ#30dx{iC0T`>X#$x1(TXU)EZ5eQJ%Xy}+bV~ZOq_d{ zAk&s~YrSQjN6pSP8Y*~PL5j`Q`p5B!TZ`vIu5u7Bn{V$d=4QLXPFmvW+qct`I}LsR z(dg*F@H9#N^Pr+`E#k`KDHSm3sK*933Yw#bVF#A0z3J?sLH#GF!SHHqj?G^VPJ$*i zZfyK592pJ^WouUf_0_S0M2@H++(Jc;IX>+=P>}Xx$|q9&5Xpz&Vd-svd`3+6^NEDF zQT?GMcM6TOCw2u8v;F>F+_6oP@mu!Td`vQ?-=6qhU-l>dQ66^h9@JhJpu#$8nXR^8 z*JR?v#Sa;pjj2lJqS41#Jti+678Q6~RH~>2QU*^p60(^EcpjEYkYM0pf#zgUv1XQ^ zi<7ThDjp|!+j5*Nken>$VTGktJS>3JsqUzQ3=hk!rk0tL1*SHZdUm1E8*{R3YwDeW z@1flD3f++sx2&Bf>?m`f7RK|iY{m-E)cmrwu}emZAq#cs&R#05<*>KD5v;#&cQ2()o7HVtTf>>~tzAoU8BQhR3F6 z&pY+#&3Sk-z-Fg8tZPcqpcb0QUJa<2b<{(HcUg{}c-3o2d(#_BIgq^?AHN2CDtooD zvEkqtSniMfVdZbly*$xYH5B^?u*6Da9#-L9tjwy>k9jY6nR0r?HrUh)h5p)%Fh4JB z`O_K!WvZc67i}ma>@N9`zEU&oqVXkH~8I{FLuu zLrZnC2C8ED_R2|dkg4HC%TtwMaV3g!!{vEcv9A*MG<=@CgU}o)UmJ^BaoN_>f$t$? zS~j~shT?b zT9}zB!F#$`U*|^OMZAK6u$<0|QRlE@E7sV0eko~q*lxpVbmWl%CU^d_&!dfv$8i)m zRNjV1YC_JXYInvbThp_g4!toWXPj&4B7t8c?wsF65m zz@~ek>gy$73$XDFzRdIVfq<;)S?!AUT<*;IuBlEXEzDlc+H-HwsiC^+KYtjC$`Rof@C2W*3Qb?=l_vl*MjtJwor>_4t|W8*IZ|Ht(d zLV|EQuFFyD1{sty=(k|GHCfs7q0()*V;mhjEqOFyx*fW!FM`tCnRyiVH)W!}^}^;h zX3(HP|yem4&I+)4$5poV^bN++r&I9DH~%rw`DVr zn5~mV*K-4@>XL$!RJ|(ZWJ%d)xOn~fvlFFLG!H8$m!c8t5fTu`c$MSma#l{IC>Tbn zcvuttTgF&TrNJAvI6TbB0=RFYz<1EHzD4mUHj~TZS6%3TZpo^*fAE5u`&%0>Umg~Z0a$Inp62ITFx8gI$5e_mP^+hmgogsnhCs|xDp)Q zD7e-666$rb$n&u9l(n(qI$3CVSi~&_jhR**=%#8|IOv6pj<1)mll7DNGVA5pSb~QI zJ}W0=v`3k!mZf=Eyt$B*RymqNwJeTJa6ByFELEtDMK+)1@_^8L8A$#x9K}+$g0V!> zvZgMK=1ijG)xGU4V1@reV`i-bm0#ZlTnG3Ba1r2JKpn5o>1RCSz^)12amT#1PMJ8n zF2W55-U|udFRQ&{==o5b^uPG0U(vX(UYCmPaNHRKJIu_29?V|#L_bR6)qrQ(o8CB5 z){ePu@MY(cLA@Ire-x5|)ok4^a|a?p%&M4ZQwt`o>9o0a0>5$AXrAbv;bAof$NGN7 zors4`*ClDOvcAqdm&|Edt874sEo!&S@T%Bi$HSp<+cw8;!a{#`Lg>>x$k{GRT0g~* z9UDK`9APQ9gz@m;X;m2PwC_;QnnP!Wva&<7U5$7&zZFGUG;ADGI~hE~h(X`X;l5RU z%5RuhK*wqA?Z4*YSH4wt8HhnC*m7I4@2sq9G;w{M$o?ONAP&Hbk^JLQ?* z$j@dv#y0)BidS~<4h_?TH^)}ltHHs$h8@P{UTtjrLpaopsgQGZhyZTXtHR+LRiNcY z4JpT+5i-CNoY!FONfgdh_$*uXz$bke3Ns zK1}`ZYk0~2+&X4u9UI03TX8AqF4xjJ#gFc&5yOF*JGxuUA1GuZ=%|k1jFw zy#($b^&Lj6*ffW`#s|fV7wDc#Zy3Y7&T7Bqhe@T@|ARWGbahQlMx*1^53A^%%;XyN zX&4>cUqCd+z7bl>hvmWPiD7wgm>qoONo7Ru#>QU<{*V9sw-v-e){qIp>AYW#+Ad9r z{4k`|qLZjhkq7*9BMsCXmIj6$T+-s^T{h1jQV+RnW8;tD;vhiEv8vXw z!VIs<_CwO2+!|-=C}sN7`csM6sT+y+K)) zncFun+cXNu^=hndE*?TG5d?Y=)@r309@aJJX$UqxCbRME6a%xa%tQFN2YkPk(J`|C z_~~`X{RHt7JkvT^#v?o&{Q1ZIF;UF+ULoGdL%QU{)xIc&Q zj)rl&8i&AQQ`+0jVS)X@2%4b$^w$y4CI@o!|L(p2ETK%t76>=d}HGm!!xjoAV>)r!>v>H z)ZsjbbY>-)An%)vg_IGJ1;2|`vVm&rm^CkrP6wpJTU zwm$uZ-dxB>d!|knyQs?Mt3*Q3nA^8KlAq#T0@M874;INP{56DEs%72teK&gN@{&0( z;P1?21Wz1L@1lDKuiWAET65FycjrPGX%!y5L+;c{Fz@D z^l1yKuJ6g=X10V(oH&_tA{SQ*5i9kxyv2>WY_0{@HWUt~nSw5CX zRKg0iv2>lRv^JI%n3LsljlrmtlLcNQxrh}`G~YAw>gQ@>`R5gqd2*<$>cw$82!hES zmwcsPvWKmjw{C%XlzVk-LbvSB0Xz9A)baWqpnvl9t++jd-T+=~W$z^BuwX7h{rUj7 z?dKcr5zeic(OQW4C7NHFOB=nIy=oxw4->CuhUvoOMEcl*Q-gQKfLiX}>2_^*BQHr0 z3@gW<9sK5ruZo8bgpG~gh>L+m5WbMD%l?q;kr$!gx*3@uJcX=7mjB27VtdH-S7mH( zqjYDsIeO*-S`L1w*W2<*i$OnkU#Rq*lo7lgzVK5Nd$K)*^-zdsj1>?Y-HLGppA)Hg zAg!6@EJyk4NH?UEd0?!V00kG2NF4!ZvZU;OH-|}WEc;g(rKk?_)Flw`4MG52|g$dQ01!gLBb-_{m?mb;|kb`Y-UXdVI^# z6moObmZBaJGtMqF9o8CzkOhh2P{yj^VR@S~agLIW<@r`Wy@z3?$;w&w!y}N6?9kPk zS#g~#C)*cMzGfE7Zg?gPF*zyp%R>>BOmG~BV>X%oGv#DKwl)^HStp#VY@IB#1fuGE zTpJ5=JS@|5o>qoX8w(T<3q@@#As?EJMVgZZpm|uCmN=}Gb1ezYMS`r2#g(uQdvuWh zm8px&anPNsn+24F3@E(lrh$qH0{1|^SB)bV}bXSxs5IEVA<4y zDeI4}Mb5ur70;<|M)%or81vI7m@C3KtFfUe^}M7t|&{!=RkKa`oW#uM0z-tADAl-`N%3#i%xxXu;>d zvOKJ!4~t7v+&Z&iZSRP&D36g)%dC4r2F)12Gn_1NhAo(&!@JC^b#@P8!8AW+`^Gxl zh=PyasL-vqN3eLB?xJNqR@iyluyCQ#HijnNX<^<8v**M4)W+a;Q>iN$a^|e}$TzD8 z?>$=7-~%&mZ+gooP}G3s-_nka4`btZ4=}ms&R%V7WO0Q1Jyll-XtH*d;#~S*J-4_I*cM0C`L1@E9q>SVjH?H?h!8%Dp-^sqV1|n8VNr zUADh5l??-$di7yJv@D)UW1st2GfiiZI6GcNtYO;kO=J4bgfp0R0)KqGs&Puu z@O}f%oS#b{VQnrxJ12k<@oL@J^AQC#4J(e;BHBy2{v;Gs!^Xx&4)}kYrx2nz`f&b8 z^ml`m{3(NBb z#q-iW#K7}P)}pjtz6CC=U5NoK^cS4vH!55($vGO96w3bCzJhMf$3=FCu~CkZu1Qsc z@Vm`0`XPyOd&QfPXam)ZT`PMI9n#sZ&lBdyE*<}q8~DxQ{NW96os8OCL`4i`BD zoB?!3@VAE0b``>N5DP|mjFV;H0%#3hFPODrr$u;v znKrudcJ2+Nf=!>`G3 zsakT({c$qXW$fq|45ori(9s`ce{UW46Wr=2+f&^B#~!A<#QhlVu}9|>`r+c39C;rS zS2D`1MQOc6Y#0sI7@$(UtxVK~UnYiZ2I#n@kb|&1EEVUWMG?m`rd_oxSuIZRuzZ~? z%fljOjah3n8HuxJ2`393j|^4KEFe;xEC{)t(-DbswXvk^=4~I!Xw(=p(aj~PjRkRS zEbvb%l>Ly_$+Bx4EZ?3#9AczO?cW!>BddD^2(>ShZc)zh7 zrlvzK)ceMV8H^9!JL%`l7C}YDVHX{_#qnW&?_0q4J~%cuekB|OYbnUzI>aL&1eZICD`1nb;#{ZrQw;B>Y(aiv^If>_L`E$(*r3&QO>ye1h(bjDpu= zZ||M`HdQ0#kPWfyCsRtw$>=PnT1&nK&%Kf|o4`ylD<1ZTpw4D;E2G>dEEvMu^UNiYu`sYC;689EdxZ+GdwZ{#=Pa-fu+ahR27DOvvG-5}ulEmW zzSRTA**a?%IntJ@Y?)M5bJvnthln%(%Ss0~m6zy)x&|;cBN68x+ir6~#jmGpOa1x8)4v^h}VGdRy6P7T>MUh>~XrYCHTy`$ttHF&oSMB`8zb zI$8Etfo@1C*VG<{A>}QN26bXwCyRCYI$6D(Eb{51!pQ?e$``@~6oFcMm==_GEsi+x*6c4a*mt187q9#>NKYCxP`p^S7HKU+|?Z@srx3j2IKK>5bH9qjwhXcTOI?kfiAyb zSy@<)a6dg%GLXtgaEqQo1e|?K`cZImyo|Yi48PY)+n;w&Tbn%3hsw6hU~=`durz$+ z083B8FA-u~x*C|Ghpc}#%n_^}U(J*m9}9c?=P0cnK$+F|9$=VLkK3RmFw zB(wPJw-#TV&ssOe#-GKl=zT4}xc+X6)IZN*o#jhSrM6)$eILqO%_gmGDP_f@o4%8} zg2{OeP?aafC*SlBJ~r>?QdbSzZy!7D4ZAFVEP=JNR~sAsC^4|o1O|=gxzg;C_<(uw zBJ53kkcZWw9r_KxnZW^WeC1MzFd08b!qvijeo7>1II-9?Y}WLm<@`Q`3})Sc?C~Y+ zFKH;uymPTp%!3b0pT<)PtXR%rd6O$VESGQXuZ<=1ZqCr_2dLp%2eIiN5NkECb=_ad z7|3mX%z>-d0b>^6E#A#yW|qY}#_z<|#voRP*hXtbF%I@BUWw0`=s8Pj}U{Rb3so38uQ%8SlNNyXd z>|2xhx9oL$>|K6!E(E20Gcw5j@KgE9T9|$M*Ura-$U`8N>Yc$Uwh^(H#^>cRu9Rdn zhKHc{iSie%) zc>N9Yk0k}9;t#gyt)i0CjF?>SxxYrn? zez8(xVX+eaxmy-9GK5Z4OP$)J(21#fww%eaPHlkv>g+pkB<49$BVvk4Z|a zt5Xn{WLQ}9X_IfZNg}n+j(%P*2K`EKi``O&Sw!e6CM>Km4S$$;z}$L4O0C3JsqzoH z=-{-4@~}z|?Q`0=r+aE5Y5acw1-5L|GT76gs7cq??@cSrY>x+E=49zf;6YHh6x~yz zPkIf$Wg)QuCXO^SaTn3i44%AoHj!F4jx@xci!Zqkru*tZ^tTY)tJ7!N@%0Dc#OOa6 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 o6qKhYB{Z`> Date: Mon, 15 Jun 2026 02:05:57 +0530 Subject: [PATCH 021/200] lint: conform UX v2 + model-migration code to #47 ruff tooling Rebasing feature/ux-v2 onto development (now carrying #47's ruff/format gate) left the model-migration and detector-rewrite files in their pre-#47 formatting. Run ruff --fix + ruff format and fix the violations that aren't auto-fixable: - model.py: provenance annotation used unimported `Dict` (F821, would NameError at import) -> `dict[str, dict[str, str]]` (PEP 585). - sam_detection._detect_with_sam: returned undefined `image_8bit` (F821) -> `image_rgb`, the 8-bit RGB image computed at the top. - agent.py / sam_detection.py: moved the `logger = ...` assignment below the import block to clear E402 (import execution order unchanged). - verifier.py / conversation.py: wrapped long log/summary f-strings and tool-schema descriptions; reflowed prompt prose (content preserved) to satisfy E501. ruff check . and ruff format --check . both pass (lint.yml CI gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/agent.py | 480 +++++++++++++--------- gently/app/detectors/hatching.py | 50 ++- gently/hardware/dispim/sam_detection.py | 480 ++++++++++++---------- gently/harness/conversation.py | 350 +++++++++------- gently/harness/detection/verifier.py | 525 +++++++++++++++--------- gently/harness/memory/file_store.py | 3 +- gently/harness/memory/model.py | 2 +- gently/settings.py | 23 +- gently/ui/web/connection_manager.py | 4 +- gently/ui/web/routes/chat.py | 2 +- gently/ui/web/routes/context.py | 15 +- 11 files changed, 1134 insertions(+), 800 deletions(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index 40618718..f68d67a7 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -14,38 +14,38 @@ import asyncio import logging import os -from typing import Dict, List, Optional, Callable, Any, TYPE_CHECKING +from collections.abc import Callable from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING, Any import anthropic import numpy as np -from ..exceptions import StorageError, AgentError +from ..exceptions import StorageError from ..settings import settings if TYPE_CHECKING: from ..ui.web.server import VisualizationServer -logger = logging.getLogger(__name__) +from gently_perception import Perceiver -from ..harness.state import ExperimentState, EmbryoState, ImageRecord -from ..harness.orchestration.plan_synthesis import PlanSynthesizer, PlanLibrary, PlanValidator +from ..core import EventType, emit, get_event_bus +from ..core.file_store import FileStore +from ..harness.conversation import ConversationManager +from ..harness.orchestration.plan_synthesis import PlanLibrary, PlanSynthesizer, PlanValidator +from ..harness.prompts.manager import PromptManager +from ..harness.session.interaction_logger import InteractionLogger +from ..harness.session.manager import SessionManager +from ..harness.session.timeline import TimelineManager +from ..harness.state import ExperimentState from ..harness.tools.registry import get_tool_registry -from gently_perception import Perceiver # Import tools package to trigger @tool decorator registration from . import tools as _tools # noqa: F401 -from ..harness.session.interaction_logger import InteractionLogger from .orchestration.timelapse import TimelapseOrchestrator -from ..harness.session.timeline import TimelineManager -from ..core import EventType, get_event_bus, emit -from ..core.file_store import FileStore - -from ..harness.conversation import ConversationManager -from ..harness.session.manager import SessionManager -from ..harness.prompts.manager import PromptManager +logger = logging.getLogger(__name__) # Shown when the agent is launched in UI-only mode (--no-api). The web UI is # fully browsable, but anything that would call Claude is disabled. @@ -71,7 +71,7 @@ class MicroscopyAgent: def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, storage_path: Path = Path("./experiment_data"), model: str = settings.models.main, microscope_client=None, @@ -113,7 +113,9 @@ def __init__( # 4.6+ models and obsolete on Fable 5 (always-on thinking); the header is # dropped so it can't conflict with the new model family. self.claude = anthropic.Anthropic( - api_key=api_key or os.getenv("ANTHROPIC_API_KEY") or ("no-api-mode" if no_api else None), + api_key=api_key + or os.getenv("ANTHROPIC_API_KEY") + or ("no-api-mode" if no_api else None), ) self.model = model @@ -125,7 +127,7 @@ def __init__( self.mode: str = "run" # Context store (agent's mind — set via set_context_store) - self.context_store: Optional[Any] = None + self.context_store: Any | None = None # Experiment state self.experiment = ExperimentState() @@ -141,8 +143,7 @@ def __init__( # Plan synthesis self.plan_synthesizer = PlanSynthesizer( - plan_library=PlanLibrary(), - validator=PlanValidator() + plan_library=PlanLibrary(), validator=PlanValidator() ) # Event bus for async messaging (must be before perception manager) @@ -163,8 +164,8 @@ def __init__( self.client = self.microscope # Callbacks - self.on_message_callback: Optional[Callable] = None - self.choice_handler: Optional[Callable] = None + self.on_message_callback: Callable | None = None + self.choice_handler: Callable | None = None # Serializes conversation turns: user turns and autonomous wake turns # must not interleave on the shared conversation_history. @@ -175,14 +176,18 @@ def __init__( # human). User turns are unaffected. _wake_choice_factory is set by the # web bridge so ASK-mode wake turns can round-trip an approval picker. self._autonomous_active = False - self._autonomous_blocked_tools = frozenset({ - "set_laser_power", "remove_embryo", "stop_timelapse", - }) + self._autonomous_blocked_tools = frozenset( + { + "set_laser_power", + "remove_embryo", + "stop_timelapse", + } + ) self._wake_choice_factory = None self._wake_choice_discard = None # Interaction logger for structured logging (research data collection) - self.interaction_logger: Optional[InteractionLogger] = None + self.interaction_logger: InteractionLogger | None = None # Event capture — durable log of every EventBus event during this # session. Substrate for offline replay / shadow-mode A/B of @@ -195,13 +200,13 @@ def __init__( self.decision_log = None # Timelapse orchestrator (initialized when microscope connected) - self.timelapse_orchestrator: Optional[TimelapseOrchestrator] = None + self.timelapse_orchestrator: TimelapseOrchestrator | None = None # Timeline manager for tracking events - self.timeline_manager: Optional[TimelineManager] = None + self.timeline_manager: TimelineManager | None = None # Visualization server for real-time feedback - self.viz_server: Optional["VisualizationServer"] = None + self.viz_server: VisualizationServer | None = None # Device-state monitor (bridges device-layer SSE → EventBus) self.device_state_monitor = None @@ -219,10 +224,10 @@ def __init__( ) # Wire tool execution context self.conversation._tool_context = { - 'agent': self, - 'client': getattr(self, 'microscope', None), - 'microscope': getattr(self, 'microscope', None), - 'databroker': getattr(self, 'databroker', None), + "agent": self, + "client": getattr(self, "microscope", None), + "microscope": getattr(self, "microscope", None), + "databroker": getattr(self, "databroker", None), } # Session manager (persistence) @@ -242,16 +247,22 @@ def __init__( success, history = self.sessions._resume_session(session_id, self.experiment) if success: self.conversation.conversation_history = history - self._emit_event(EventType.SESSION_RESTORED, { - 'session_id': session_id, - 'embryo_count': len(self.experiment.embryos), - 'message_count': len(self.conversation.conversation_history), - }) + self._emit_event( + EventType.SESSION_RESTORED, + { + "session_id": session_id, + "embryo_count": len(self.experiment.embryos), + "message_count": len(self.conversation.conversation_history), + }, + ) else: self.sessions.create_session() - self._emit_event(EventType.SESSION_STARTED, { - 'session_id': self.sessions.session_id, - }) + self._emit_event( + EventType.SESSION_STARTED, + { + "session_id": self.sessions.session_id, + }, + ) # Initialize interaction logger (for research data collection) self._init_interaction_logger() @@ -284,6 +295,7 @@ def __init__( # autonomously; enabled via the set_autonomy tool. try: from gently.app.wake_router import WakeRouter + self.wake_router = WakeRouter(self, self._event_bus) except Exception: logger.exception("Failed to init wake-router") @@ -300,7 +312,7 @@ def session_id(self) -> str: return self.sessions.session_id @property - def _session_id(self) -> Optional[str]: + def _session_id(self) -> str | None: """Internal session ID (backward compat).""" return self.sessions._session_id @@ -309,7 +321,7 @@ def _session_id(self, value): self.sessions._session_id = value @property - def conversation_history(self) -> List[Dict]: + def conversation_history(self) -> list[dict]: """Get conversation history.""" return self.conversation.conversation_history @@ -354,6 +366,7 @@ def set_context_store(self, context_store) -> None: self.prompts.context_store = context_store # Create agent memory harness from ..harness.memory.interface import AgentMemory + self.memory = AgentMemory(context_store, session_id=self.session_id) self.prompts.memory = self.memory @@ -363,6 +376,7 @@ def enter_plan_mode(self) -> str: return "Already in plan mode." self.mode = "plan" import gently.harness.plan_mode.tools # noqa: F401 + self._update_system_prompt() emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "plan"}, source="agent") logger.info("Entered plan mode") @@ -442,7 +456,8 @@ def exit_plan_mode(self) -> str: if item and self.session_id and self.context_store: try: self.context_store.link_session_campaign( - self.session_id, item.campaign_id, + self.session_id, + item.campaign_id, ) except Exception: pass @@ -468,11 +483,14 @@ def exit_plan_mode(self) -> str: def _update_system_prompt(self, context_summary: str | None = None): """Rebuild system prompt via PromptManager.""" self.system_prompt = self.prompts.update_system_prompt( - self.experiment, self.client, self.mode, context_summary, + self.experiment, + self.client, + self.mode, + context_summary, perceiver=getattr(self, "perceiver", None), ) - def _get_active_plan_summary(self) -> Optional[str]: + def _get_active_plan_summary(self) -> str | None: """Delegation shim for agent bridge access.""" return self.prompts.get_active_plan_summary() @@ -502,7 +520,7 @@ def _auto_save(self): self.experiment, self.conversation.conversation_history, self.system_prompt ) - def list_sessions(self) -> List[Dict]: + def list_sessions(self) -> list[dict]: """List available sessions.""" return self.sessions.list_sessions() @@ -535,6 +553,7 @@ def _init_event_capture(self): a stripped-down agent) — replay just won't have a log to read. """ from gently.eval import EventCapture + try: session_dir = None sid = self.session_id @@ -542,7 +561,8 @@ def _init_event_capture(self): session_dir = self.store._session_dir(sid) if session_dir is None: logging.getLogger(__name__).debug( - "EventCapture: no session dir for %s — skipping", sid) + "EventCapture: no session dir for %s — skipping", sid + ) return path = session_dir / "events.jsonl" self.event_capture = EventCapture(path) @@ -569,6 +589,7 @@ def _init_decision_log(self): logs and the two are diffed offline. """ from gently.eval import DecisionLog + try: session_dir = None sid = self.session_id @@ -576,7 +597,8 @@ def _init_decision_log(self): session_dir = self.store._session_dir(sid) if session_dir is None: logging.getLogger(__name__).debug( - "DecisionLog: no session dir for %s — skipping", sid) + "DecisionLog: no session dir for %s — skipping", sid + ) return path = session_dir / "decisions.jsonl" self.decision_log = DecisionLog(path) @@ -674,12 +696,15 @@ def on_stage_detected(event): embryo_id = data.get("embryo_id") if embryo_id and embryo_id in self.experiment.embryos: embryo = self.experiment.embryos[embryo_id] - embryo.add_cv_result("stage_classification", { - "stage": data.get("stage"), - "confidence": data.get("confidence"), - "nuclei_count": data.get("nuclei_count"), - "timepoint": data.get("timepoint"), - }) + embryo.add_cv_result( + "stage_classification", + { + "stage": data.get("stage"), + "confidence": data.get("confidence"), + "nuclei_count": data.get("nuclei_count"), + "timepoint": data.get("timepoint"), + }, + ) except Exception as e: logger.warning(f"Error handling stage detected event: {e}") @@ -702,23 +727,31 @@ def on_perception(event): stage = data.get("stage") # 'no_object' is an empty-field sentinel, not a developmental # stage — don't mirror it into latest_developmental_stage. - if (not stage or stage == "no_object" or not embryo_id - or embryo_id not in self.experiment.embryos): + if ( + not stage + or stage == "no_object" + or not embryo_id + or embryo_id not in self.experiment.embryos + ): return embryo = self.experiment.embryos[embryo_id] if stage == getattr(embryo, "latest_developmental_stage", None): return # steady state — nothing new to mirror - embryo.add_cv_result("stage_classification", { - "stage": stage, - "timepoint": data.get("timepoint"), - "stability": data.get("stability"), - "temporal_analysis": data.get("temporal_analysis"), - "detector_name": "perception", - }) + embryo.add_cv_result( + "stage_classification", + { + "stage": stage, + "timepoint": data.get("timepoint"), + "stability": data.get("stability"), + "temporal_analysis": data.get("temporal_analysis"), + "detector_name": "perception", + }, + ) self.invalidate_context_cache() self._auto_save() - logger.info("Perception: %s -> stage %s (t%s)", - embryo_id, stage, data.get("timepoint")) + logger.info( + "Perception: %s -> stage %s (t%s)", embryo_id, stage, data.get("timepoint") + ) except Exception as e: logger.warning(f"Error handling perception event: {e}") @@ -733,7 +766,9 @@ def on_perception(event): # ===== Visualization Server Methods ===== - async def start_viz_server(self, port: int = settings.network.viz_port, ssl_certfile=None, ssl_keyfile=None): + async def start_viz_server( + self, port: int = settings.network.viz_port, ssl_certfile=None, ssl_keyfile=None + ): """Start the visualization server for real-time feedback.""" if self.viz_server is not None: logger.info("Visualization server already running") @@ -783,6 +818,7 @@ async def start_viz_server(self, port: int = settings.network.viz_port, ssl_cert if self.microscope is not None and self.device_state_monitor is None: try: from .device_state_monitor import DeviceStateMonitor + self.device_state_monitor = DeviceStateMonitor(self.microscope) await self.device_state_monitor.start() logger.info("Device-state monitor started") @@ -796,6 +832,7 @@ async def start_viz_server(self, port: int = settings.network.viz_port, ssl_cert if self.microscope is not None and self.bottom_camera_monitor is None: try: from .bottom_camera_monitor import BottomCameraStreamMonitor + self.bottom_camera_monitor = BottomCameraStreamMonitor(self.microscope) logger.info("Bottom-camera monitor ready (not started)") except Exception as e: @@ -826,16 +863,14 @@ def push_viz( array: np.ndarray, uid: str, data_type: str = "image", - metadata: Optional[Dict[str, Any]] = None, + metadata: dict[str, Any] | None = None, ): """Non-blocking push of image to visualization server.""" if self.viz_server is None: return try: - asyncio.create_task( - self.viz_server.push_image(array, uid, data_type, metadata or {}) - ) + asyncio.create_task(self.viz_server.push_image(array, uid, data_type, metadata or {})) except RuntimeError: pass except Exception as e: @@ -854,7 +889,7 @@ def _has_microscope(self) -> bool: """ return self.client is not None - def _emit_event(self, event_type: EventType, data: Optional[Dict] = None): + def _emit_event(self, event_type: EventType, data: dict | None = None): """Emit an event to the event bus.""" self._event_bus.publish( event_type=event_type, @@ -895,10 +930,13 @@ def _publish_embryos_update(self) -> None: def _mark_significant_action(self, action_type: str): """Mark that a significant action occurred (triggers auto-save).""" self._auto_save() - self._emit_event(EventType.SESSION_SAVED, { - 'session_id': self.sessions._session_id, - 'action_type': action_type, - }) + self._emit_event( + EventType.SESSION_SAVED, + { + "session_id": self.sessions._session_id, + "action_type": action_type, + }, + ) # ===== Public Message API ===== @@ -917,8 +955,11 @@ async def handle_message(self, user_message: str) -> str: Response from agent """ if quick_response := self.conversation.try_quick_response( - user_message, self.experiment, self.mode, - self.enter_plan_mode, self.exit_plan_mode, + user_message, + self.experiment, + self.mode, + self.enter_plan_mode, + self.exit_plan_mode, ): return quick_response @@ -932,10 +973,7 @@ async def handle_message(self, user_message: str) -> str: self._update_system_prompt(context_summary) # Add user message to history - self.conversation.conversation_history.append({ - "role": "user", - "content": user_message - }) + self.conversation.conversation_history.append({"role": "user", "content": user_message}) tools = self._get_tools_for_mode() cached_prompt = self._get_cached_system_prompt() @@ -962,14 +1000,17 @@ async def handle_message_stream(self, user_message: str): Chunks with 'type' and data """ if quick_response := self.conversation.try_quick_response( - user_message, self.experiment, self.mode, - self.enter_plan_mode, self.exit_plan_mode, + user_message, + self.experiment, + self.mode, + self.enter_plan_mode, + self.exit_plan_mode, ): - yield {'type': 'text', 'text': quick_response} + yield {"type": "text", "text": quick_response} return if not self.api_enabled: - yield {'type': 'text', 'text': _NO_API_NOTICE} + yield {"type": "text", "text": _NO_API_NOTICE} return # Hold the turn-lock for the whole streamed turn so an autonomous wake @@ -985,16 +1026,14 @@ async def handle_message_stream(self, user_message: str): ) self._update_system_prompt(context_summary) - self.conversation.conversation_history.append({ - "role": "user", - "content": user_message - }) + self.conversation.conversation_history.append({"role": "user", "content": user_message}) tools = self._get_tools_for_mode() cached_prompt = self._get_cached_system_prompt() inner_gen = self.conversation.call_claude_stream( - cached_prompt, tools, + cached_prompt, + tools, tool_label_fn=self.conversation.tool_label, auto_save_fn=self._auto_save, ) @@ -1089,8 +1128,11 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): choice_data = chunk.get("choice_data", {}) if isinstance(chunk, dict) else {} factory = getattr(self, "_wake_choice_factory", None) if not interactive or factory is None: - logger.info("Wake picker auto-cancelled (interactive=%s, channel=%s)", - interactive, factory is not None) + logger.info( + "Wake picker auto-cancelled (interactive=%s, channel=%s)", + interactive, + factory is not None, + ) return "cancelled" try: future = factory(choice_data) # registers future + sets request_id @@ -1100,6 +1142,7 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): request_id = choice_data.get("request_id", "") await emit({**chunk, "origin": "wake", "request_id": request_id}) from gently.app.wake_router import ASK_TIMEOUT_SEC + try: selected = await asyncio.wait_for(future, timeout=ASK_TIMEOUT_SEC) except asyncio.TimeoutError: @@ -1122,7 +1165,7 @@ async def _resolve_wake_choice(self, chunk, emit, interactive): pass return selected or "skip" - async def get_tool_call(self, user_message: str) -> Optional[Dict]: + async def get_tool_call(self, user_message: str) -> dict | None: """Dry-run tool call (for benchmarking).""" context_summary = await self.prompts.get_cached_context_summary( self.experiment, self.timelapse_orchestrator, self.timeline_manager @@ -1134,25 +1177,25 @@ async def get_tool_call(self, user_message: str) -> Optional[Dict]: # === Experiment Management Methods === - def load_embryos_from_database(self, database: Dict): + def load_embryos_from_database(self, database: dict): """Load embryos from calibration database.""" - if 'embryos' not in database: + if "embryos" not in database: return - for embryo_id, embryo_data in database['embryos'].items(): - position = embryo_data.get('stage_position_after_centering_um', {}) - calibration = embryo_data.get('calibration', {}) + for embryo_id, embryo_data in database["embryos"].items(): + position = embryo_data.get("stage_position_after_centering_um", {}) + calibration = embryo_data.get("calibration", {}) self.experiment.add_embryo( embryo_id=embryo_id, position=position, calibration=calibration, - uid=embryo_data.get('uid'), + uid=embryo_data.get("uid"), ) self._update_system_prompt() - def import_embryos_from_session(self, session_id: str, clear_existing: bool = False) -> Dict: + def import_embryos_from_session(self, session_id: str, clear_existing: bool = False) -> dict: """ Import embryos from another session into the current experiment. @@ -1208,14 +1251,14 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa if not embryo_states: session_data = self.store.load_session_snapshot(session_id) if session_data: - embryo_states = session_data.get('embryo_states', {}) + embryo_states = session_data.get("embryo_states", {}) if not embryo_states: return { - 'success': False, - 'error': "No embryos found in session", - 'imported': [], - 'skipped': [], + "success": False, + "error": "No embryos found in session", + "imported": [], + "skipped": [], } if clear_existing: @@ -1235,30 +1278,30 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa # Prefer explicit coarse/fine when the snapshot has them # (FileStore path); fall back to flat stage_position for the # legacy JSON-snapshot path which only carries the resolved view. - position_coarse = embryo_data.get('position_coarse') - position_fine = embryo_data.get('position_fine') + position_coarse = embryo_data.get("position_coarse") + position_fine = embryo_data.get("position_fine") if position_coarse is None and position_fine is None: - position_coarse = embryo_data.get('stage_position', {}) - calibration = embryo_data.get('calibration', {}) - source_uid = embryo_data.get('uid') or f"{session_id}_{embryo_id}" + position_coarse = embryo_data.get("stage_position", {}) + calibration = embryo_data.get("calibration", {}) + source_uid = embryo_data.get("uid") or f"{session_id}_{embryo_id}" self.experiment.add_embryo( embryo_id=embryo_id, position=position_coarse or {}, position_fine=position_fine or {}, calibration=calibration, - user_label=embryo_data.get('user_label'), + user_label=embryo_data.get("user_label"), uid=source_uid, - role=embryo_data.get('role') or 'unassigned', + role=embryo_data.get("role") or "unassigned", ) embryo = self.experiment.embryos[embryo_id] - embryo.nickname = embryo_data.get('nickname') - embryo.interval_seconds = embryo_data.get('interval_seconds') - embryo.num_slices = embryo_data.get('num_slices', 50) - embryo.exposure_ms = embryo_data.get('exposure_ms', 10.0) - embryo.priority = embryo_data.get('priority', 'normal') - embryo.acquisition_mode = embryo_data.get('acquisition_mode', 'volume') + embryo.nickname = embryo_data.get("nickname") + embryo.interval_seconds = embryo_data.get("interval_seconds") + embryo.num_slices = embryo_data.get("num_slices", 50) + embryo.exposure_ms = embryo_data.get("exposure_ms", 10.0) + embryo.priority = embryo_data.get("priority", "normal") + embryo.acquisition_mode = embryo_data.get("acquisition_mode", "volume") # Light budget import. Prefer fields already on embryo_data # (future schema may persist these directly on embryo.yaml); @@ -1269,27 +1312,22 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa # removed and the import should fail loudly if dose is # missing. dose = self._compute_imported_dose(session_id, embryo_id) - embryo.exposure_count = ( - embryo_data.get('exposure_count') - or dose['exposure_count'] - ) + embryo.exposure_count = embryo_data.get("exposure_count") or dose["exposure_count"] embryo.total_exposure_ms = ( - embryo_data.get('total_exposure_ms') - or dose['total_exposure_ms'] + embryo_data.get("total_exposure_ms") or dose["total_exposure_ms"] ) embryo.timepoints_acquired = ( - embryo_data.get('timepoints_acquired') - or dose['exposure_count'] + embryo_data.get("timepoints_acquired") or dose["exposure_count"] ) - last_imaged_str = embryo_data.get('last_imaged') + last_imaged_str = embryo_data.get("last_imaged") if last_imaged_str: try: embryo.last_imaged = datetime.fromisoformat(last_imaged_str) except (ValueError, TypeError): - embryo.last_imaged = dose['last_imaged'] + embryo.last_imaged = dose["last_imaged"] else: - embryo.last_imaged = dose['last_imaged'] + embryo.last_imaged = dose["last_imaged"] imported.append(embryo_id) @@ -1300,14 +1338,14 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa self._mark_significant_action("embryo_import") return { - 'success': len(imported) > 0, - 'imported': imported, - 'skipped': skipped, - 'errors': errors, - 'source_session': session_id, + "success": len(imported) > 0, + "imported": imported, + "skipped": skipped, + "errors": errors, + "source_session": session_id, } - def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> Dict: + def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> dict: """Reconstruct an embryo's realized 488 nm photodose from the source session's per-volume meta files. @@ -1322,66 +1360,68 @@ def _compute_imported_dose(self, source_session_id: str, embryo_id: str) -> Dict TODO: replace with reading a persisted ``dose:`` block from embryo.yaml once dose-tracking is first-class. """ - import yaml from datetime import datetime from pathlib import Path + import yaml + result = { - 'exposure_count': 0, - 'total_exposure_ms': 0.0, - 'last_imaged': None, + "exposure_count": 0, + "total_exposure_ms": 0.0, + "last_imaged": None, } if not self.store: return result # FileStore exposes _session_dir(session_id) → resolved Path. - session_dir_fn = getattr(self.store, '_session_dir', None) + session_dir_fn = getattr(self.store, "_session_dir", None) sd = session_dir_fn(source_session_id) if callable(session_dir_fn) else None if sd is None: return result - vols_dir = Path(sd) / 'embryos' / embryo_id / 'volumes' + vols_dir = Path(sd) / "embryos" / embryo_id / "volumes" if not vols_dir.is_dir(): return result latest = None - for meta_path in sorted(vols_dir.glob('*.meta.yaml')): + for meta_path in sorted(vols_dir.glob("*.meta.yaml")): try: doc = yaml.safe_load(meta_path.read_text()) or {} except Exception: continue - md = doc.get('metadata') or {} - num_slices = md.get('num_slices') + md = doc.get("metadata") or {} + num_slices = md.get("num_slices") if num_slices is None: - shape = doc.get('shape') or [] + shape = doc.get("shape") or [] num_slices = shape[0] if shape else 0 - exposure_ms = md.get('exposure_ms') or 0.0 + exposure_ms = md.get("exposure_ms") or 0.0 try: - result['total_exposure_ms'] += float(num_slices) * float(exposure_ms) + result["total_exposure_ms"] += float(num_slices) * float(exposure_ms) except (TypeError, ValueError): pass - result['exposure_count'] += 1 - acq = doc.get('acquired_at') + result["exposure_count"] += 1 + acq = doc.get("acquired_at") if acq and (latest is None or acq > latest): latest = acq if latest: try: - result['last_imaged'] = datetime.fromisoformat(latest) + result["last_imaged"] = datetime.fromisoformat(latest) except (ValueError, TypeError): pass return result - async def on_volume_acquired(self, embryo_id: str, timepoint: int, - volume_data, volume_path=None): + async def on_volume_acquired( + self, embryo_id: str, timepoint: int, volume_data, volume_path=None + ): """Callback when a volume is acquired.""" embryo = self.experiment.embryos.get(embryo_id) if not embryo: return - if hasattr(volume_data, 'read_volume'): + if hasattr(volume_data, "read_volume"): volume = volume_data.read_volume() else: volume = volume_data @@ -1390,7 +1430,8 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, if self.store and self.session_id: try: self.store.register_embryo( - self.session_id, embryo_id, + self.session_id, + embryo_id, position_coarse=embryo.position_coarse or None, position_fine=embryo.position_fine or None, calibration=embryo.calibration, @@ -1407,14 +1448,19 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, } if volume_path is not None: stored_path = self.store.register_volume( - self.session_id, embryo_id, timepoint, + self.session_id, + embryo_id, + timepoint, incoming_path=Path(volume_path), metadata=acq_metadata, volume_data=volume, ) else: stored_path = self.store.put_volume( - self.session_id, embryo_id, timepoint, volume, + self.session_id, + embryo_id, + timepoint, + volume, metadata=acq_metadata, ) except StorageError: @@ -1429,9 +1475,9 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, if self.viz_server and volume is not None: try: from gently.core.imaging import ( - projection_three_view, - compute_crop_bounds, apply_crop_bounds, + compute_crop_bounds, + projection_three_view, ) view_a = volume[0] if volume.ndim == 4 else volume @@ -1439,14 +1485,18 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, if view_a.ndim == 3: z_depth, height, width = view_a.shape if width > height * 2: - view_a = view_a[:, :, :width // 2] + view_a = view_a[:, :, : width // 2] bounds = compute_crop_bounds(view_a) cropped = apply_crop_bounds(view_a, bounds) three_view_img, _ = projection_three_view(cropped) else: three_view_img = view_a.astype(np.float32) if three_view_img.max() > three_view_img.min(): - three_view_img = (three_view_img - three_view_img.min()) / (three_view_img.max() - three_view_img.min()) * 255 + three_view_img = ( + (three_view_img - three_view_img.min()) + / (three_view_img.max() - three_view_img.min()) + * 255 + ) three_view_img = three_view_img.astype(np.uint8) self.push_viz( @@ -1454,29 +1504,32 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, uid=projection_uid, data_type="volume_projection", metadata={ - 'embryo_id': embryo_id, - 'timepoint': timepoint, - 'shape': list(volume.shape), - 'projection_uid': projection_uid, - 'volume_uid': volume_uid, - 'projection_type': 'three_view', - } + "embryo_id": embryo_id, + "timepoint": timepoint, + "shape": list(volume.shape), + "projection_uid": projection_uid, + "volume_uid": volume_uid, + "projection_type": "three_view", + }, ) except Exception as e: logger.warning(f"Failed to push to viz: {e}") - self._emit_event(EventType.VOLUME_ACQUIRED, { - 'embryo_id': embryo_id, - 'timepoint': timepoint, - 'volume_uid': volume_uid, - 'projection_uid': projection_uid, - 'volume_path': str(stored_path) if stored_path else None, - 'shape': list(volume.shape), - }) + self._emit_event( + EventType.VOLUME_ACQUIRED, + { + "embryo_id": embryo_id, + "timepoint": timepoint, + "volume_uid": volume_uid, + "projection_uid": projection_uid, + "volume_path": str(stored_path) if stored_path else None, + "shape": list(volume.shape), + }, + ) return { - 'volume_uid': volume_uid, - 'projection_uid': projection_uid, + "volume_uid": volume_uid, + "projection_uid": projection_uid, } def should_stop_experiment(self) -> bool: @@ -1485,22 +1538,31 @@ def should_stop_experiment(self) -> bool: return False return all(e.should_skip for e in self.experiment.embryos.values()) - def get_embryo_acquisition_order(self) -> List[str]: + def get_embryo_acquisition_order(self) -> list[str]: """Get embryo acquisition order based on priority.""" - high = [e.id for e in self.experiment.embryos.values() if e.priority == "high" and not e.should_skip] - normal = [e.id for e in self.experiment.embryos.values() if e.priority == "normal" and not e.should_skip] - low = [e.id for e in self.experiment.embryos.values() if e.priority == "low" and not e.should_skip] + high = [ + e.id + for e in self.experiment.embryos.values() + if e.priority == "high" and not e.should_skip + ] + normal = [ + e.id + for e in self.experiment.embryos.values() + if e.priority == "normal" and not e.should_skip + ] + low = [ + e.id + for e in self.experiment.embryos.values() + if e.priority == "low" and not e.should_skip + ] return high + normal + low - def decide_parameters(self, embryo_id: str, timepoint: int) -> Dict: + def decide_parameters(self, embryo_id: str, timepoint: int) -> dict: """Get current acquisition parameters for embryo.""" embryo = self.experiment.embryos.get(embryo_id) if not embryo: - return {'num_slices': 50, 'exposure_ms': 10.0} - return { - 'num_slices': embryo.num_slices, - 'exposure_ms': embryo.exposure_ms - } + return {"num_slices": 50, "exposure_ms": 10.0} + return {"num_slices": embryo.num_slices, "exposure_ms": embryo.exposure_ms} def decide_next_interval(self, timepoint: int) -> float: """Decide interval until next timepoint.""" @@ -1525,8 +1587,9 @@ async def check_blank_image( logger.warning(f"[BLANK_CHECK] {embryo_id}: Numerical check indicates blank image") return True - import io import base64 + import io + from PIL import Image if max_proj.max() > 0: @@ -1536,10 +1599,11 @@ async def check_blank_image( img = Image.fromarray(normalized) buffer = io.BytesIO() - img.save(buffer, format='PNG') + img.save(buffer, format="PNG") b64_image = base64.b64encode(buffer.getvalue()).decode() - prompt = """Look at this microscopy image. Is this a VALID microscopy image or a BLANK/CORRUPTED image? + prompt = """\ +Look at this microscopy image. Is this a VALID microscopy image or a BLANK/CORRUPTED image? A BLANK or CORRUPTED image shows: - Mostly uniform gray/black with no structure @@ -1557,20 +1621,22 @@ async def check_blank_image( self.claude.messages.create, model=settings.models.fast, max_tokens=10, - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": b64_image - } - } - ] - }] + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": b64_image, + }, + }, + ], + } + ], ) result = response.content[0].text.strip().upper() @@ -1581,7 +1647,11 @@ async def check_blank_image( return is_blank - except (anthropic.APIConnectionError, anthropic.RateLimitError, anthropic.APIStatusError) as e: + except ( + anthropic.APIConnectionError, + anthropic.RateLimitError, + anthropic.APIStatusError, + ) as e: logger.error(f"[BLANK_CHECK] Claude API error for {embryo_id}: {e}") return False except Exception as e: diff --git a/gently/app/detectors/hatching.py b/gently/app/detectors/hatching.py index ca63acd3..6dc0705d 100644 --- a/gently/app/detectors/hatching.py +++ b/gently/app/detectors/hatching.py @@ -14,7 +14,7 @@ import asyncio import logging import time -from typing import Any, Dict, Optional +from typing import Any import numpy as np @@ -24,7 +24,9 @@ logger = logging.getLogger(__name__) -_HATCHING_PROMPT = """You are observing a C. elegans embryo on a microscope. Decide whether the embryo has HATCHED, then record your decision with the record_hatching tool. +_HATCHING_PROMPT = """\ +You are observing a C. elegans embryo on a microscope. Decide whether the embryo has HATCHED, +then record your decision with the record_hatching tool. A HATCHED embryo: - Has visibly broken out of the eggshell @@ -69,19 +71,21 @@ class HatchingDetector(Detector): name = "hatching" - def __init__(self, claude_client=None, model: Optional[str] = None): + def __init__(self, claude_client=None, model: str | None = None): self._claude = claude_client self._model = model async def run( self, volume: np.ndarray, - context: Dict[str, Any], + context: dict[str, Any], ) -> DetectorResult: - from gently.settings import settings import json + import anthropic + from gently.settings import settings + embryo_id = context.get("embryo_id", "?") timepoint = int(context.get("timepoint", 0)) start = time.time() @@ -113,24 +117,28 @@ async def run( max_tokens=256, tools=[_HATCHING_TOOL], tool_choice={"type": "tool", "name": _HATCHING_TOOL["name"]}, - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": _HATCHING_PROMPT}, - {"type": "image", "source": { - "type": "base64", - "media_type": "image/png", - "data": b64_image, - }}, - ], - }], + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": _HATCHING_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": b64_image, + }, + }, + ], + } + ], ) # Forced tool_choice guarantees a tool_use block; read its parsed # input directly. No regex, no JSON-from-prose fallback. tool_input = next( - (b.input for b in response.content - if getattr(b, "type", None) == "tool_use"), + (b.input for b in response.content if getattr(b, "type", None) == "tool_use"), None, ) @@ -156,7 +164,11 @@ async def run( error=err, ) - except (anthropic.APIConnectionError, anthropic.RateLimitError, anthropic.APIStatusError) as e: + except ( + anthropic.APIConnectionError, + anthropic.RateLimitError, + anthropic.APIStatusError, + ) as e: logger.error("[%s] Claude API error for %s: %s", self.name, embryo_id, e) return DetectorResult( detector_name=self.name, diff --git a/gently/hardware/dispim/sam_detection.py b/gently/hardware/dispim/sam_detection.py index 7014338a..45355438 100644 --- a/gently/hardware/dispim/sam_detection.py +++ b/gently/hardware/dispim/sam_detection.py @@ -5,30 +5,28 @@ Returns embryo positions (pixel + stage coordinates) for calibration workflow. """ -import logging -import time +import base64 import json +import logging +import os import uuid -import numpy as np +from io import BytesIO from pathlib import Path + +import anthropic import cv2 -import base64 -from io import BytesIO +import numpy as np from PIL import Image -import anthropic -from typing import Dict, List, Tuple, Optional -import os - -from gently.settings import settings - -logger = logging.getLogger(__name__) from gently.core.coordinates import ( - pixel_to_stage_position, - get_um_per_pixel, - DEFAULT_PIXEL_SIZE_UM, DEFAULT_OBJECTIVE_MAG, + DEFAULT_PIXEL_SIZE_UM, + get_um_per_pixel, + pixel_to_stage_position, ) +from gently.settings import settings + +logger = logging.getLogger(__name__) class SAMEmbryoDetector: @@ -42,11 +40,13 @@ class SAMEmbryoDetector: - Returns embryo positions as simple list of coordinates """ - def __init__(self, - sam_checkpoint: str = "sam_vit_b_01ec64.pth", - sam_model_type: str = "vit_b", - device: str = "cpu", - anthropic_api_key: Optional[str] = None): + def __init__( + self, + sam_checkpoint: str = "sam_vit_b_01ec64.pth", + sam_model_type: str = "vit_b", + device: str = "cpu", + anthropic_api_key: str | None = None, + ): """ Initialize SAM detector @@ -85,7 +85,7 @@ def _load_sam(self): if self._mask_generator is not None: return - from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor + from segment_anything import SamAutomaticMaskGenerator, SamPredictor, sam_model_registry if not Path(self.sam_checkpoint).exists(): raise FileNotFoundError(f"SAM checkpoint not found: {self.sam_checkpoint}") @@ -108,13 +108,15 @@ def _load_sam(self): self._predictor = SamPredictor(sam) logger.info("SAM model loaded") - def preprocess_image(self, - image: np.ndarray, - bg_kernel_size: int = 150, - use_clahe: bool = True, - clahe_clip_limit: float = 3.0, - clahe_tile_size: int = 16, - gaussian_sigma: float = 2.0) -> np.ndarray: + def preprocess_image( + self, + image: np.ndarray, + bg_kernel_size: int = 150, + use_clahe: bool = True, + clahe_clip_limit: float = 3.0, + clahe_tile_size: int = 16, + gaussian_sigma: float = 2.0, + ) -> np.ndarray: """ Preprocess image for better SAM detection. @@ -151,14 +153,18 @@ def preprocess_image(self, # This stretches the narrow range (e.g., 84-354) to full 0-255 logger.debug("Percentile normalization (2-98%%)...") p2, p98 = np.percentile(image, (2, 98)) - img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype(np.uint8) + img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype( + np.uint8 + ) logger.debug("Normalized to 0-255") # Step 2: Background subtraction with large morphological opening # Removes large-scale illumination variations if bg_kernel_size > 0: logger.debug("Background subtraction (kernel=%d)...", bg_kernel_size) - kernel_bg = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (bg_kernel_size, bg_kernel_size)) + kernel_bg = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (bg_kernel_size, bg_kernel_size) + ) background = cv2.morphologyEx(img_norm, cv2.MORPH_OPEN, kernel_bg) img_no_bg = cv2.subtract(img_norm, background) img_no_bg = cv2.normalize(img_no_bg, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) @@ -171,8 +177,7 @@ def preprocess_image(self, if use_clahe: logger.debug("CLAHE (clip=%.1f, tile=%d)...", clahe_clip_limit, clahe_tile_size) clahe = cv2.createCLAHE( - clipLimit=clahe_clip_limit, - tileGridSize=(clahe_tile_size, clahe_tile_size) + clipLimit=clahe_clip_limit, tileGridSize=(clahe_tile_size, clahe_tile_size) ) img_enhanced = clahe.apply(img_no_bg) logger.debug("CLAHE applied") @@ -187,16 +192,20 @@ def preprocess_image(self, else: img_smooth = img_enhanced - logger.debug("Preprocessing complete (output range: %s - %s)", img_smooth.min(), img_smooth.max()) + logger.debug( + "Preprocessing complete (output range: %s - %s)", img_smooth.min(), img_smooth.max() + ) return img_smooth - def find_embryo_candidates(self, - image: np.ndarray, - brightness_percentile: float = 99.0, - min_area: int = 5000, - max_area: int = 150000, - clahe_clip: float = 3.0, - clahe_tile: int = 16) -> Tuple[List[Dict], np.ndarray]: + def find_embryo_candidates( + self, + image: np.ndarray, + brightness_percentile: float = 99.0, + min_area: int = 5000, + max_area: int = 150000, + clahe_clip: float = 3.0, + clahe_tile: int = 16, + ) -> tuple[list[dict], np.ndarray]: """ Find embryo candidates using brightness-based detection. @@ -229,12 +238,16 @@ def find_embryo_candidates(self, enhanced_image : np.ndarray Contrast-enhanced 8-bit image for SAM """ - logger.info("Finding embryo candidates (brightness percentile=%.1f)...", brightness_percentile) + logger.info( + "Finding embryo candidates (brightness percentile=%.1f)...", brightness_percentile + ) logger.debug("Input range: %s - %s", image.min(), image.max()) # Step 1: Percentile normalization (handles low dynamic range) p2, p98 = np.percentile(image, (2, 98)) - img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype(np.uint8) + img_norm = np.clip((image.astype(np.float32) - p2) / (p98 - p2) * 255, 0, 255).astype( + np.uint8 + ) logger.debug("Normalized to 0-255") # Step 2: CLAHE for local contrast enhancement @@ -261,7 +274,9 @@ def find_embryo_candidates(self, logger.debug("Morphological cleanup complete") # Step 7: Find connected components and filter by area - num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) candidates = [] for i in range(1, num_labels): # Skip background (label 0) @@ -273,19 +288,14 @@ def find_embryo_candidates(self, h = stats[i, cv2.CC_STAT_HEIGHT] cx, cy = centroids[i] - candidates.append({ - 'bbox': (x, y, w, h), - 'centroid': (cx, cy), - 'area': area - }) + candidates.append({"bbox": (x, y, w, h), "centroid": (cx, cy), "area": area}) logger.info("Found %d embryo candidates", len(candidates)) return candidates, img_smooth - def refine_with_sam(self, - image: np.ndarray, - candidates: List[Dict], - padding: int = 20) -> List[Dict]: + def refine_with_sam( + self, image: np.ndarray, candidates: list[dict], padding: int = 20 + ) -> list[dict]: """ Refine embryo candidates using SAM with bounding box prompts. @@ -322,7 +332,7 @@ def refine_with_sam(self, h, w = image.shape[:2] for i, candidate in enumerate(candidates): - x, y, bw, bh = candidate['bbox'] + x, y, bw, bh = candidate["bbox"] # Add padding and clip to image bounds x1 = max(0, x - padding) @@ -335,10 +345,7 @@ def refine_with_sam(self, # Get SAM prediction with box prompt masks, scores, _ = self._predictor.predict( - point_coords=None, - point_labels=None, - box=input_box, - multimask_output=True + point_coords=None, point_labels=None, box=input_box, multimask_output=True ) # Take best mask (highest score) @@ -348,9 +355,7 @@ def refine_with_sam(self, # Calculate properties from SAM mask contours, _ = cv2.findContours( - mask.astype(np.uint8), - cv2.RETR_EXTERNAL, - cv2.CHAIN_APPROX_SIMPLE + mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if contours: @@ -364,41 +369,47 @@ def refine_with_sam(self, cx = M["m10"] / M["m00"] cy = M["m01"] / M["m00"] else: - cx, cy = candidate['centroid'] + cx, cy = candidate["centroid"] # Calculate circularity perimeter = cv2.arcLength(contour, True) - circularity = 4 * np.pi * area / (perimeter ** 2) if perimeter > 0 else 0 + circularity = 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0 # Get bounding box from contour bx, by, bw, bh = cv2.boundingRect(contour) - embryos.append({ - 'embryo_id': f'embryo_{i + 1}', - 'uid': str(uuid.uuid4()), # Global unique identifier for cross-session tracking - 'pixel_x': float(cx), - 'pixel_y': float(cy), - 'bbox': (bx, by, bw, bh), # Used by visualization functions - 'area_pixels': int(area), - 'circularity': float(circularity), - 'confidence': float(score), - 'mask': mask - }) + embryos.append( + { + "embryo_id": f"embryo_{i + 1}", + "uid": str( + uuid.uuid4() + ), # Global unique identifier for cross-session tracking + "pixel_x": float(cx), + "pixel_y": float(cy), + "bbox": (bx, by, bw, bh), # Used by visualization functions + "area_pixels": int(area), + "circularity": float(circularity), + "confidence": float(score), + "mask": mask, + } + ) logger.info("SAM refined %d embryos", len(embryos)) return embryos - async def detect_embryos(self, - image: np.ndarray, - stage_position: Tuple[float, float], - pixel_size_um: float = DEFAULT_PIXEL_SIZE_UM, - objective_mag: float = DEFAULT_OBJECTIVE_MAG, - use_claude_review: bool = True, - save_visualizations: bool = True, - output_dir: Optional[Path] = None, - brightness_percentile: float = 99.0, - min_area: int = 5000, - max_area: int = 150000) -> Dict: + async def detect_embryos( + self, + image: np.ndarray, + stage_position: tuple[float, float], + pixel_size_um: float = DEFAULT_PIXEL_SIZE_UM, + objective_mag: float = DEFAULT_OBJECTIVE_MAG, + use_claude_review: bool = True, + save_visualizations: bool = True, + output_dir: Path | None = None, + brightness_percentile: float = 99.0, + min_area: int = 5000, + max_area: int = 150000, + ) -> dict: """ Detect embryos using brightness-based detection + SAM refinement. @@ -462,20 +473,17 @@ async def detect_embryos(self, # Step 1: Find candidates using brightness detection logger.info("[1/4] Finding embryo candidates (brightness-based)...") candidates, image_enhanced = self.find_embryo_candidates( - image, - brightness_percentile=brightness_percentile, - min_area=min_area, - max_area=max_area + image, brightness_percentile=brightness_percentile, min_area=min_area, max_area=max_area ) if len(candidates) == 0: logger.warning("No embryo candidates found!") return { - 'embryos': [], - 'initial_detections': 0, - 'final_detections': 0, - 'verification': {'verified': False}, - 'images': {} + "embryos": [], + "initial_detections": 0, + "final_detections": 0, + "verification": {"verified": False}, + "images": {}, } # Step 2: Refine with SAM @@ -489,11 +497,11 @@ async def detect_embryos(self, if len(embryos_sam) == 0: logger.warning("No embryos detected by SAM!") return { - 'embryos': [], - 'initial_detections': 0, - 'final_detections': 0, - 'verification': {'verified': False}, - 'images': {} + "embryos": [], + "initial_detections": 0, + "final_detections": 0, + "verification": {"verified": False}, + "images": {}, } # Save initial detection @@ -503,8 +511,8 @@ async def detect_embryos(self, # Claude review (if enabled) embryos_final = embryos_sam - verification = {'verified': True, 'skipped': not use_claude_review} - changes = {'round1': {'removed': [], 'added': []}} + verification = {"verified": True, "skipped": not use_claude_review} + changes = {"round1": {"removed": [], "added": []}} if use_claude_review and self.claude_client: logger.info("[2/4] Claude Vision review (Round 1)...") @@ -512,7 +520,7 @@ async def detect_embryos(self, review_r1 = await self._review_with_claude(image_8bit, annotated, embryos_sam) logger.info("[3/4] Applying corrections...") - embryos_r1, changes['round1'] = self._apply_corrections( + embryos_r1, changes["round1"] = self._apply_corrections( embryos_sam, review_r1, image, self._predictor ) @@ -524,22 +532,22 @@ async def detect_embryos(self, logger.info("[4/4] Claude verification (Round 2)...") r1_viz = self._create_annotated_image(image_8bit, embryos_r1) verification = await self._verify_with_claude( - image_8bit, r1_viz, embryos_r1, changes['round1'] + image_8bit, r1_viz, embryos_r1, changes["round1"] ) # Apply round 2 corrections if needed has_r2_changes = ( - len(verification.get('additional_false_positives', [])) > 0 or - len(verification.get('additional_false_negatives', [])) > 0 + len(verification.get("additional_false_positives", [])) > 0 + or len(verification.get("additional_false_negatives", [])) > 0 ) if has_r2_changes: logger.info("Applying Round 2 corrections...") review_r2 = { - 'false_positives': verification.get('additional_false_positives', []), - 'false_negatives': verification.get('additional_false_negatives', []) + "false_positives": verification.get("additional_false_positives", []), + "false_negatives": verification.get("additional_false_negatives", []), } - embryos_final, changes['round2'] = self._apply_corrections( + embryos_final, changes["round2"] = self._apply_corrections( embryos_r1, review_r2, image, self._predictor ) else: @@ -553,7 +561,7 @@ async def detect_embryos(self, stage_position, pixel_size_um, objective_mag, - image_shape=image.shape[:2] # (height, width) + image_shape=image.shape[:2], # (height, width) ) # Save final visualization @@ -563,19 +571,19 @@ async def detect_embryos(self, # Package results results = { - 'embryos': embryo_positions, - 'initial_detections': len(embryos_sam), - 'final_detections': len(embryos_final), - 'verification': verification, - 'changes': changes, - 'images': { - 'initial': str(output_dir / "detection_initial.png"), - 'final': str(output_dir / "detection_final.png") - } + "embryos": embryo_positions, + "initial_detections": len(embryos_sam), + "final_detections": len(embryos_final), + "verification": verification, + "changes": changes, + "images": { + "initial": str(output_dir / "detection_initial.png"), + "final": str(output_dir / "detection_final.png"), + }, } if use_claude_review and save_visualizations: - results['images']['round1'] = str(output_dir / "detection_round1.png") + results["images"]["round1"] = str(output_dir / "detection_round1.png") logger.info("=" * 70) logger.info("DETECTION COMPLETE: %d embryos", len(embryo_positions)) @@ -594,7 +602,7 @@ def _to_rgb8(image: np.ndarray) -> np.ndarray: return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) return image - def _detect_with_sam(self, image: np.ndarray) -> Tuple[List[Dict], np.ndarray]: + def _detect_with_sam(self, image: np.ndarray) -> tuple[list[dict], np.ndarray]: """Run SAM automatic segmentation (extracted from test script)""" image_rgb = self._to_rgb8(image) @@ -604,14 +612,16 @@ def _detect_with_sam(self, image: np.ndarray) -> Tuple[List[Dict], np.ndarray]: # Filter candidates embryo_candidates = [] for mask_data in masks: - area = mask_data['area'] + area = mask_data["area"] if not (self.min_area <= area <= self.max_area): continue - bbox = mask_data['bbox'] - mask = mask_data['segmentation'] - contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + bbox = mask_data["bbox"] + mask = mask_data["segmentation"] + contours, _ = cv2.findContours( + mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) if len(contours) == 0: continue @@ -620,40 +630,44 @@ def _detect_with_sam(self, image: np.ndarray) -> Tuple[List[Dict], np.ndarray]: if perimeter == 0: continue - circularity = 4 * np.pi * area / (perimeter ** 2) + circularity = 4 * np.pi * area / (perimeter**2) if circularity < self.min_circularity: continue - embryo_candidates.append({ - 'mask': mask, - 'bbox': bbox, - 'area': area, - 'circularity': circularity, - 'stability_score': mask_data['stability_score'], - 'predicted_iou': mask_data['predicted_iou'] - }) + embryo_candidates.append( + { + "mask": mask, + "bbox": bbox, + "area": area, + "circularity": circularity, + "stability_score": mask_data["stability_score"], + "predicted_iou": mask_data["predicted_iou"], + } + ) # Sort by quality and apply spatial separation - embryo_candidates.sort(key=lambda x: (x['area'] * x['stability_score']), reverse=True) + embryo_candidates.sort(key=lambda x: x["area"] * x["stability_score"], reverse=True) selected_embryos = [] for candidate in embryo_candidates: if len(selected_embryos) >= self.max_embryos: break - bbox = candidate['bbox'] + bbox = candidate["bbox"] candidate_center_x = bbox[0] + bbox[2] / 2 candidate_center_y = bbox[1] + bbox[3] / 2 too_close = False for selected in selected_embryos: - sel_bbox = selected['bbox'] + sel_bbox = selected["bbox"] sel_center_x = sel_bbox[0] + sel_bbox[2] / 2 sel_center_y = sel_bbox[1] + sel_bbox[3] / 2 - distance = np.sqrt((candidate_center_x - sel_center_x)**2 + - (candidate_center_y - sel_center_y)**2) + distance = np.sqrt( + (candidate_center_x - sel_center_x) ** 2 + + (candidate_center_y - sel_center_y) ** 2 + ) if distance < self.min_separation_pixels: too_close = True @@ -662,19 +676,27 @@ def _detect_with_sam(self, image: np.ndarray) -> Tuple[List[Dict], np.ndarray]: if not too_close: selected_embryos.append(candidate) - return selected_embryos, image_8bit + return selected_embryos, image_rgb - def _create_annotated_image(self, image: np.ndarray, embryos: List[Dict]) -> np.ndarray: + def _create_annotated_image(self, image: np.ndarray, embryos: list[dict]) -> np.ndarray: """Create annotated image with numbered boxes""" viz = image.copy() if len(viz.shape) == 2: viz = cv2.cvtColor(viz, cv2.COLOR_GRAY2RGB) - colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), - (255, 0, 255), (0, 255, 255), (128, 128, 0), (128, 0, 128)] + colors = [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (255, 0, 255), + (0, 255, 255), + (128, 128, 0), + (128, 0, 128), + ] for i, embryo in enumerate(embryos): - bbox = embryo['bbox'] + bbox = embryo["bbox"] x, y, w, h = bbox color = colors[i % len(colors)] @@ -713,7 +735,7 @@ def _encode_image_base64(self, image: np.ndarray) -> str: buffered = BytesIO() pil_image.save(buffered, format="JPEG", quality=quality, optimize=True) if buffered.tell() <= max_bytes: - return base64.b64encode(buffered.getvalue()).decode('utf-8') + return base64.b64encode(buffered.getvalue()).decode("utf-8") quality -= 5 # Last resort @@ -722,18 +744,21 @@ def _encode_image_base64(self, image: np.ndarray) -> str: pil_image = pil_image.resize(new_size, Image.Resampling.LANCZOS) buffered = BytesIO() pil_image.save(buffered, format="JPEG", quality=85, optimize=True) - return base64.b64encode(buffered.getvalue()).decode('utf-8') + return base64.b64encode(buffered.getvalue()).decode("utf-8") - async def _review_with_claude(self, image: np.ndarray, annotated: np.ndarray, embryos: List[Dict]) -> Dict: + async def _review_with_claude( + self, image: np.ndarray, annotated: np.ndarray, embryos: list[dict] + ) -> dict: """Round 1: Claude reviews detections (from test script)""" if not self.claude_client: - return {'false_positives': [], 'false_negatives': []} + return {"false_positives": [], "false_negatives": []} image_base64 = self._encode_image_base64(annotated) - prompt = f"""You are a microscopy expert analyzing embryo detections from a bottom camera view. + prompt = f"""\ +You are a microscopy expert analyzing embryo detections from a bottom camera view. -CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos)-1} with colored bounding boxes. +CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos) - 1} with colored bounding boxes. EMBRYO CHARACTERISTICS: - Small, BRIGHT white/light gray oval or rice grain shapes @@ -763,14 +788,25 @@ async def _review_with_claude(self, image: np.ndarray, annotated: np.ndarray, em message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=8000, - output_config={"effort": "high"}, # was thinking budget_tokens (Opus 4.8 rejects it) - messages=[{ - "role": "user", - "content": [ - {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_base64}}, - {"type": "text", "text": prompt} - ] - }] + output_config={ + "effort": "high" + }, # was thinking budget_tokens (Opus 4.8 rejects it) + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_base64, + }, + }, + {"type": "text", "text": prompt}, + ], + } + ], ) response_text = next((b.text for b in message.content if b.type == "text"), "") @@ -787,18 +823,19 @@ async def _review_with_claude(self, image: np.ndarray, annotated: np.ndarray, em except Exception as e: logger.warning("Claude review failed: %s", e) - return {'false_positives': [], 'false_negatives': []} + return {"false_positives": [], "false_negatives": []} - async def _verify_with_claude(self, image: np.ndarray, annotated: np.ndarray, - embryos: List[Dict], previous_changes: Dict) -> Dict: + async def _verify_with_claude( + self, image: np.ndarray, annotated: np.ndarray, embryos: list[dict], previous_changes: dict + ) -> dict: """Round 2: Claude verifies corrections (from test script)""" if not self.claude_client: - return {'verified': True, 'skipped': True} + return {"verified": True, "skipped": True} image_base64 = self._encode_image_base64(annotated) - removed = previous_changes.get('removed', []) - added = previous_changes.get('added', []) + removed = previous_changes.get("removed", []) + added = previous_changes.get("added", []) prompt = f"""VERIFICATION ROUND - You previously reviewed this image. @@ -806,7 +843,7 @@ async def _verify_with_claude(self, image: np.ndarray, annotated: np.ndarray, - Removed: {removed if removed else "none"} - Added: {added if added else "none"} -CURRENT: {len(embryos)} detections (numbered 0-{len(embryos)-1}) +CURRENT: {len(embryos)} detections (numbered 0-{len(embryos) - 1}) TASK: Verify corrections and catch any remaining issues. Only report CLEAR remaining problems. @@ -823,14 +860,25 @@ async def _verify_with_claude(self, image: np.ndarray, annotated: np.ndarray, message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=6000, - output_config={"effort": "high"}, # was thinking budget_tokens (Opus 4.8 rejects it) - messages=[{ - "role": "user", - "content": [ - {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_base64}}, - {"type": "text", "text": prompt} - ] - }] + output_config={ + "effort": "high" + }, # was thinking budget_tokens (Opus 4.8 rejects it) + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_base64, + }, + }, + {"type": "text", "text": prompt}, + ], + } + ], ) response_text = next((b.text for b in message.content if b.type == "text"), "") @@ -846,38 +894,41 @@ async def _verify_with_claude(self, image: np.ndarray, annotated: np.ndarray, except Exception as e: logger.warning("Verification failed: %s", e) - return {'verified': False} + return {"verified": False} - def _apply_corrections(self, embryos: List[Dict], review: Dict, - image: np.ndarray, predictor) -> Tuple[List[Dict], Dict]: + def _apply_corrections( + self, embryos: list[dict], review: dict, image: np.ndarray, predictor + ) -> tuple[list[dict], dict]: """Apply Claude's corrections (from test script)""" corrected = [] - changes = {'removed': [], 'added': []} + changes = {"removed": [], "added": []} # Remove false positives - false_positives = set(review.get('false_positives', [])) + false_positives = set(review.get("false_positives", [])) if false_positives: - changes['removed'] = list(false_positives) + changes["removed"] = list(false_positives) for i, embryo in enumerate(embryos): if i not in false_positives: corrected.append(embryo) # Add false negatives - false_negatives = review.get('false_negatives', []) + false_negatives = review.get("false_negatives", []) if false_negatives: for fn in false_negatives: - point = (fn['x'], fn['y']) + point = (fn["x"], fn["y"]) new_embryo = self._segment_with_sam(image, predictor, point) - if new_embryo and (self.min_area <= new_embryo['area'] <= self.max_area and - new_embryo['circularity'] >= self.min_circularity): + if new_embryo and ( + self.min_area <= new_embryo["area"] <= self.max_area + and new_embryo["circularity"] >= self.min_circularity + ): corrected.append(new_embryo) - changes['added'].append(point) + changes["added"].append(point) return corrected, changes - def _segment_with_sam(self, image: np.ndarray, predictor, point: Tuple) -> Optional[Dict]: + def _segment_with_sam(self, image: np.ndarray, predictor, point: tuple) -> dict | None: """Use SAM predictor to segment region (from test script)""" image_rgb = self._to_rgb8(image) predictor.set_image(image_rgb) @@ -886,9 +937,7 @@ def _segment_with_sam(self, image: np.ndarray, predictor, point: Tuple) -> Optio point_labels = np.array([1]) masks, scores, _ = predictor.predict( - point_coords=point_coords, - point_labels=point_labels, - multimask_output=True + point_coords=point_coords, point_labels=point_labels, multimask_output=True ) best_idx = np.argmax(scores) @@ -903,26 +952,33 @@ def _segment_with_sam(self, image: np.ndarray, predictor, point: Tuple) -> Optio bbox = [x_min, y_min, x_max - x_min, y_max - y_min] area = mask.sum() - contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours, _ = cv2.findContours( + mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) if len(contours) > 0: perimeter = cv2.arcLength(contours[0], True) - circularity = 4 * np.pi * area / (perimeter ** 2) if perimeter > 0 else 0 + circularity = 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0 else: circularity = 0 return { - 'mask': mask, - 'bbox': bbox, - 'area': int(area), - 'circularity': float(circularity), - 'stability_score': float(scores[best_idx]), - 'predicted_iou': float(scores[best_idx]) + "mask": mask, + "bbox": bbox, + "area": int(area), + "circularity": float(circularity), + "stability_score": float(scores[best_idx]), + "predicted_iou": float(scores[best_idx]), } - def _pixel_to_stage_coordinates(self, embryos: List[Dict], stage_pos: Tuple[float, float], - pixel_size_um: float, objective_mag: float, - image_shape: Tuple[int, int] = (2048, 2048)) -> List[Dict]: + def _pixel_to_stage_coordinates( + self, + embryos: list[dict], + stage_pos: tuple[float, float], + pixel_size_um: float, + objective_mag: float, + image_shape: tuple[int, int] = (2048, 2048), + ) -> list[dict]: """ Convert pixel coordinates to stage coordinates. @@ -938,7 +994,7 @@ def _pixel_to_stage_coordinates(self, embryos: List[Dict], stage_pos: Tuple[floa embryo_positions = [] for i, embryo in enumerate(embryos): - bbox = embryo['bbox'] + bbox = embryo["bbox"] x, y, w, h = bbox center_x_px = x + w / 2 @@ -953,24 +1009,26 @@ def _pixel_to_stage_coordinates(self, embryos: List[Dict], stage_pos: Tuple[floa image_center_y=image_center_y, stage_x=stage_x, stage_y=stage_y, - um_per_pixel=effective_pixel_um + um_per_pixel=effective_pixel_um, ) - embryo_positions.append({ - 'embryo_id': f'embryo_{i + 1}', - 'pixel_x': float(center_x_px), - 'pixel_y': float(center_y_px), - 'stage_x_um': float(embryo_stage_x), - 'stage_y_um': float(embryo_stage_y), - 'bbox_pixel': tuple(bbox), - 'area_pixels': embryo.get('area_pixels', embryo.get('area', 0)), - 'circularity': embryo.get('circularity', 0), - 'confidence': embryo.get('confidence', embryo.get('stability_score', 0)) - }) + embryo_positions.append( + { + "embryo_id": f"embryo_{i + 1}", + "pixel_x": float(center_x_px), + "pixel_y": float(center_y_px), + "stage_x_um": float(embryo_stage_x), + "stage_y_um": float(embryo_stage_y), + "bbox_pixel": tuple(bbox), + "area_pixels": embryo.get("area_pixels", embryo.get("area", 0)), + "circularity": embryo.get("circularity", 0), + "confidence": embryo.get("confidence", embryo.get("stability_score", 0)), + } + ) return embryo_positions - def show_in_napari(self, image: np.ndarray, embryos: List[Dict], block: bool = False): + def show_in_napari(self, image: np.ndarray, embryos: list[dict], block: bool = False): """Deprecated: napari display was retired in Phase 1. SAM detection results are now reviewed via the web map view — diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 29573e6a..9085e96b 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -10,14 +10,14 @@ import logging import re import time -from typing import Dict, List, Optional, Any +from typing import Any from ..settings import settings logger = logging.getLogger(__name__) -def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: +def _extend_tool_calls(out: list[dict[str, Any]], content_blocks) -> None: """Append every tool_use block in content_blocks to out. Tolerates absent attributes (some SDK versions / mock objects) so it @@ -29,11 +29,13 @@ def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: try: if getattr(block, "type", None) != "tool_use": continue - out.append({ - "name": getattr(block, "name", None), - "input": getattr(block, "input", None), - "id": getattr(block, "id", None), - }) + out.append( + { + "name": getattr(block, "name", None), + "input": getattr(block, "input", None), + "id": getattr(block, "id", None), + } + ) except Exception: continue @@ -55,7 +57,7 @@ def __init__(self, client, model, tool_registry): self._tool_registry = tool_registry # Conversation state - self.conversation_history: List[Dict] = [] + self.conversation_history: list[dict] = [] # Token counters self.total_input_tokens: int = 0 @@ -76,8 +78,9 @@ def __init__(self, client, model, tool_registry): # ===== Quick Response ===== - def try_quick_response(self, message: str, experiment, mode: str, - enter_plan_fn, exit_plan_fn) -> Optional[str]: + def try_quick_response( + self, message: str, experiment, mode: str, enter_plan_fn, exit_plan_fn + ) -> str | None: """ Answer simple queries from state without LLM call. @@ -106,7 +109,13 @@ def try_quick_response(self, message: str, experiment, mode: str, return experiment.get_summary() # Plan mode switching via natural language - plan_enter_phrases = ("plan mode", "enter plan", "switch to plan", "let's plan", "design an experiment") + plan_enter_phrases = ( + "plan mode", + "enter plan", + "switch to plan", + "let's plan", + "design an experiment", + ) plan_exit_phrases = ("exit plan", "leave plan", "back to run", "run mode") if mode != "plan" and any(p in message_lower for p in plan_enter_phrases): @@ -142,22 +151,25 @@ def should_use_thinking(self, message: str, mode: str) -> bool: if mode == "plan": return True - import re msg_lower = message.lower() - if re.search(r'\bthink(ing)?\b', message, re.IGNORECASE): + if re.search(r"\bthink(ing)?\b", message, re.IGNORECASE): return True - if re.search(r'\bcalibrat', msg_lower): + if re.search(r"\bcalibrat", msg_lower): return True - if re.search(r'\b(plan|timelapse|time-lapse|acquisition)\b', msg_lower): + if re.search(r"\b(plan|timelapse|time-lapse|acquisition)\b", msg_lower): return True - if re.search(r'\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)', msg_lower): + if re.search( + r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)", msg_lower + ): return True - if re.search(r'\b(all|every|each)\s+(embryo|sample)', msg_lower): + if re.search(r"\b(all|every|each)\s+(embryo|sample)", msg_lower): return True - if re.search(r'\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b', msg_lower): + if re.search( + r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b", msg_lower + ): return True - if re.search(r'\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)', msg_lower): + if re.search(r"\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)", msg_lower): return True return False @@ -172,6 +184,7 @@ async def _create_with_refusal_fallback(self, api_kwargs): working whether or not Fable 5 is currently serviceable. The moment the org retention is fixed, Fable 5 serves with no code change.""" from anthropic import BadRequestError + fb = settings.models.refusal_fallback model = api_kwargs.get("model") try: @@ -180,7 +193,9 @@ async def _create_with_refusal_fallback(self, api_kwargs): if not fb or fb == model: raise logger.warning("Model %s rejected the request (400); falling back to %s", model, fb) - return await self._call_api_with_retry(self.claude.messages.create, **{**api_kwargs, "model": fb}) + return await self._call_api_with_retry( + self.claude.messages.create, **{**api_kwargs, "model": fb} + ) if response.stop_reason == "refusal" and fb and fb != model: logger.warning("Model %s declined the turn; retrying on %s", model, fb) response = await self._call_api_with_retry( @@ -188,8 +203,9 @@ async def _create_with_refusal_fallback(self, api_kwargs): ) return response - async def call_claude(self, user_message: str, system_prompt, tools, - mode: str, auto_save_fn) -> str: + async def call_claude( + self, user_message: str, system_prompt, tools, mode: str, auto_save_fn + ) -> str: """ Call Claude API with full context and tool access (non-streaming). @@ -221,8 +237,8 @@ async def call_claude(self, user_message: str, system_prompt, tools, interaction = self.interaction_logger.start_interaction( user_prompt=user_message, system_state={ - 'acquisition_status': 'unknown', - } + "acquisition_status": "unknown", + }, ) # Snapshot inputs for decision capture BEFORE the tool loop starts @@ -233,13 +249,15 @@ async def call_claude(self, user_message: str, system_prompt, tools, if self.decision_log is not None: try: from gently.eval import prompt_hash as _prompt_hash + decision_prompt_hash = _prompt_hash( - system_prompt, list(self.conversation_history), + system_prompt, + list(self.conversation_history), ) except Exception: logger.exception("Failed to compute decision prompt_hash") - tool_calls_collected: List[Dict[str, Any]] = [] + tool_calls_collected: list[dict[str, Any]] = [] assistant_message = "" error_occurred = None @@ -262,18 +280,10 @@ async def call_claude(self, user_message: str, system_prompt, tools, # Process tool calls while response.stop_reason == "tool_use": - tool_results = await self._execute_tools_with_logging( - response.content, interaction - ) + tool_results = await self._execute_tools_with_logging(response.content, interaction) - self.conversation_history.append({ - "role": "assistant", - "content": response.content - }) - self.conversation_history.append({ - "role": "user", - "content": tool_results - }) + self.conversation_history.append({"role": "assistant", "content": response.content}) + self.conversation_history.append({"role": "user", "content": tool_results}) api_kwargs["messages"] = self.conversation_history response = await self._create_with_refusal_fallback(api_kwargs) @@ -283,20 +293,20 @@ async def call_claude(self, user_message: str, system_prompt, tools, # Extract text response. Fable 5 may refuse (stop_reason="refusal") # with empty content — surface it instead of returning blank. if response.stop_reason == "refusal": - assistant_message = "(The request was declined by the model's safety system. Try rephrasing.)" + assistant_message = ( + "(The request was declined by the model's safety system. Try rephrasing.)" + ) else: assistant_message = "" for block in response.content: - if hasattr(block, 'text'): + if hasattr(block, "text"): assistant_message += block.text - self.conversation_history.append({ - "role": "assistant", - "content": response.content - }) + self.conversation_history.append({"role": "assistant", "content": response.content}) except Exception as e: import traceback + error_occurred = str(e) error_tb = traceback.format_exc() assistant_message = f"Error: {error_occurred}" @@ -343,11 +353,11 @@ def _write_production_decision( self, *, user_message: str, - tool_calls: List[Dict[str, Any]], + tool_calls: list[dict[str, Any]], response_text: str, duration_ms: float, - prompt_hash_value: Optional[str], - error: Optional[str], + prompt_hash_value: str | None, + error: str | None, ) -> None: """Persist one production Decision row (best-effort). @@ -359,24 +369,28 @@ def _write_production_decision( return try: from datetime import datetime + from gently.eval import Decision, DecisionTrigger - self.decision_log.append(Decision( - timestamp=datetime.now(), - agent="production", - trigger=DecisionTrigger.USER_MESSAGE, - trigger_detail=(user_message or "")[:200], - tool_calls=tool_calls, - response_text=response_text, - prompt_hash=prompt_hash_value, - duration_ms=duration_ms, - error=error, - )) + + self.decision_log.append( + Decision( + timestamp=datetime.now(), + agent="production", + trigger=DecisionTrigger.USER_MESSAGE, + trigger_detail=(user_message or "")[:200], + tool_calls=tool_calls, + response_text=response_text, + prompt_hash=prompt_hash_value, + duration_ms=duration_ms, + error=error, + ) + ) except Exception: logger.exception("Failed to write production Decision") # ===== Dry-Run Tool Call (Benchmarking) ===== - async def get_tool_call(self, user_message: str, system_prompt, tools) -> Optional[Dict]: + async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | None: """ Get what tool Claude would call without executing it (dry-run mode). @@ -399,10 +413,7 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> Option start_time = time.time() messages = self.conversation_history.copy() - messages.append({ - "role": "user", - "content": user_message - }) + messages.append({"role": "user", "content": user_message}) try: api_kwargs = { @@ -413,15 +424,12 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> Option "max_tokens": 4096, } - response = await self._call_api_with_retry( - self.claude.messages.create, - **api_kwargs - ) + response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) latency_ms = (time.time() - start_time) * 1000 - input_tokens = getattr(response.usage, 'input_tokens', 0) - output_tokens = getattr(response.usage, 'output_tokens', 0) + input_tokens = getattr(response.usage, "input_tokens", 0) + output_tokens = getattr(response.usage, "output_tokens", 0) # A refusal returns empty content — treat as "no tool call". if response.stop_reason == "refusal" or not response.content: @@ -444,7 +452,7 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> Option # ===== Tool Execution ===== - async def _execute_tools_with_logging(self, content_blocks, interaction) -> List[Dict]: + async def _execute_tools_with_logging(self, content_blocks, interaction) -> list[dict]: """ Execute Claude's tool calls with interaction logging. @@ -476,7 +484,10 @@ async def _execute_tools_with_logging(self, content_blocks, interaction) -> List if self.choice_handler and isinstance(result, str): try: choice_data = json.loads(result) - if isinstance(choice_data, dict) and choice_data.get("_type") == CHOICE_RESPONSE_TYPE: + if ( + isinstance(choice_data, dict) + and choice_data.get("_type") == CHOICE_RESPONSE_TYPE + ): user_selection = await self.choice_handler(choice_data) result = user_selection except (json.JSONDecodeError, TypeError): @@ -500,19 +511,20 @@ async def _execute_tools_with_logging(self, content_blocks, interaction) -> List error_message=error_message, ) - results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": result, - "is_error": is_error, - }) + results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + "is_error": is_error, + } + ) return results # ===== Streaming API Call ===== - async def call_claude_stream(self, system_prompt, tools, - tool_label_fn, auto_save_fn): + async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_save_fn): """ Call Claude API with streaming enabled. @@ -542,11 +554,12 @@ def _run(m): system=system_prompt, messages=self.conversation_history, tools=tools, - max_tokens=4096 + max_tokens=4096, ) as stream: for event in stream: events.append(event) return events, stream.get_final_message() + try: return _run(model) except BadRequestError: @@ -555,7 +568,9 @@ def _run(m): fb = settings.models.refusal_fallback if not fb or fb == model: raise - logger.warning("Stream model %s rejected the request (400); falling back to %s", model, fb) + logger.warning( + "Stream model %s rejected the request (400); falling back to %s", model, fb + ) return _run(fb) # Run streaming in thread with retry logic @@ -568,15 +583,24 @@ def _run(m): self._track_token_usage(final_message) break except APIStatusError as e: - error_type = getattr(e, 'body', {}) + error_type = getattr(e, "body", {}) if isinstance(error_type, dict): - error_type = error_type.get('error', {}).get('type', '') + error_type = error_type.get("error", {}).get("type", "") - if error_type in ('overloaded_error', 'rate_limit_error') or 'overloaded' in str(e).lower(): + if ( + error_type in ("overloaded_error", "rate_limit_error") + or "overloaded" in str(e).lower() + ): if attempt < max_retries - 1: - wait_time = retry_delay * (2 ** attempt) - logger.warning(f"API overloaded, retrying in {wait_time:.1f}s (attempt {attempt + 1}/{max_retries})") - yield {'type': 'text', 'text': f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n"} + wait_time = retry_delay * (2**attempt) + logger.warning( + f"API overloaded, retrying in {wait_time:.1f}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + yield { + "type": "text", + "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", + } await asyncio.sleep(wait_time) continue raise @@ -596,34 +620,41 @@ def _run(m): # discard any partial, don't iterate empty content or process tools. if final_message.stop_reason == "refusal": logger.warning("Claude declined the request (model=%s)", self.model) - yield {'type': 'text', 'text': "(The request was declined by the model's safety system. Try rephrasing.)"} + yield { + "type": "text", + "text": "(The request was declined by the model's safety system. Try rephrasing.)", + } return # Diagnostic: per-response counts. DEBUG, not WARNING — stop_reason=tool_use # with matching tool blocks is normal; the genuine anomaly is the # logger.error below (tool blocks present but stop_reason != tool_use). tool_block_count = sum( - 1 for b in final_message.content - if hasattr(b, 'type') and b.type == 'tool_use' + 1 for b in final_message.content if hasattr(b, "type") and b.type == "tool_use" ) logger.debug( - "Claude response: stop_reason=%s, content_blocks=%d, tool_use_blocks=%d, tools_passed=%d, model=%s", - final_message.stop_reason, len(final_message.content), - tool_block_count, len(tools), self.model, + "Claude response: stop_reason=%s, content_blocks=%d, " + "tool_use_blocks=%d, tools_passed=%d, model=%s", + final_message.stop_reason, + len(final_message.content), + tool_block_count, + len(tools), + self.model, ) if tool_block_count > 0 and final_message.stop_reason != "tool_use": logger.error( "BUG: Claude returned %d tool_use blocks but stop_reason=%s (expected 'tool_use')", - tool_block_count, final_message.stop_reason, + tool_block_count, + final_message.stop_reason, ) # Process events and yield text full_text = [] for event in events: if event.type == "content_block_delta": - if hasattr(event.delta, 'text'): + if hasattr(event.delta, "text"): full_text.append(event.delta.text) - yield {'type': 'text', 'text': event.delta.text} + yield {"type": "text", "text": event.delta.text} # Detect fake XML tool calls in text (Claude writing tool_use as text) joined_text = "".join(full_text) @@ -631,7 +662,8 @@ def _run(m): logger.error( "DETECTED: Claude wrote XML tool tags as plain text instead of " "using API tool_use mechanism. stop_reason=%s, text_preview=%.200s", - final_message.stop_reason, joined_text[:200], + final_message.stop_reason, + joined_text[:200], ) response_content = final_message.content @@ -645,14 +677,14 @@ def _run(m): tool_results = [] for block in response_content: - if hasattr(block, 'type') and block.type == "tool_use": + if hasattr(block, "type") and block.type == "tool_use": start_time = time.time() yield { - 'type': 'tool_start', - 'tool_name': block.name, - 'tool_input': block.input, - 'tool_label': tool_label_fn(block.name, block.input), + "type": "tool_start", + "tool_name": block.name, + "tool_input": block.input, + "tool_label": tool_label_fn(block.name, block.input), } is_error_flag = False @@ -663,31 +695,37 @@ def _run(m): if isinstance(tool_result, str): try: from gently.app.tools.interaction_tools import CHOICE_RESPONSE_TYPE + choice_data = json.loads(tool_result) - if isinstance(choice_data, dict) and choice_data.get("_type") == CHOICE_RESPONSE_TYPE: + if ( + isinstance(choice_data, dict) + and choice_data.get("_type") == CHOICE_RESPONSE_TYPE + ): user_selection = yield { - 'type': 'choice_request', - 'choice_data': choice_data + "type": "choice_request", + "choice_data": choice_data, } tool_result = user_selection or "cancelled" except (json.JSONDecodeError, TypeError): pass - result_text = tool_result if isinstance(tool_result, str) else str(tool_result) - tool_results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": tool_result - }) + result_text = ( + tool_result if isinstance(tool_result, str) else str(tool_result) + ) + tool_results.append( + {"type": "tool_result", "tool_use_id": block.id, "content": tool_result} + ) except Exception as e: is_error_flag = True result_text = f"Error: {str(e)}" - tool_results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": result_text, - "is_error": True - }) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result_text, + "is_error": True, + } + ) # First non-empty line of the result, trimmed — gives the chat # UI a one-line summary so the operator can see what a tool did @@ -700,22 +738,16 @@ def _run(m): result_summary = result_summary[:139] + "…" yield { - 'type': 'tool_call', - 'tool_name': block.name, - 'tool_input': block.input, - 'duration': time.time() - start_time, - 'result_summary': result_summary, - 'is_error': is_error_flag, + "type": "tool_call", + "tool_name": block.name, + "tool_input": block.input, + "duration": time.time() - start_time, + "result_summary": result_summary, + "is_error": is_error_flag, } - self.conversation_history.append({ - "role": "assistant", - "content": response_content - }) - self.conversation_history.append({ - "role": "user", - "content": tool_results - }) + self.conversation_history.append({"role": "assistant", "content": response_content}) + self.conversation_history.append({"role": "user", "content": tool_results}) auto_save_fn() @@ -737,15 +769,12 @@ def _run(m): else: # No tool calls - add final message to history - self.conversation_history.append({ - "role": "assistant", - "content": response_content - }) + self.conversation_history.append({"role": "assistant", "content": response_content}) auto_save_fn() # ===== Tool Label ===== - def tool_label(self, tool_name: str, tool_input: Dict) -> str: + def tool_label(self, tool_name: str, tool_input: dict) -> str: """Build a human-readable label for a tool call. Used in tool_start chunks so the TUI shows biologist-friendly @@ -759,8 +788,13 @@ def tool_label(self, tool_name: str, tool_input: Dict) -> str: campaign = self.context_store.get_campaign(campaign_id) if campaign: campaign_label = campaign.shorthand or campaign.description - if tool_name in ("propose_plan", "get_plan_status", "export_plan", - "snapshot_plan", "list_plan_versions"): + if tool_name in ( + "propose_plan", + "get_plan_status", + "export_plan", + "snapshot_plan", + "list_plan_versions", + ): return campaign_label if tool_name == "create_campaign" and inp.get("parent_id"): return f"phase under {campaign_label}" @@ -778,8 +812,12 @@ def tool_label(self, tool_name: str, tool_input: Dict) -> str: # Item reference tools item_ref = inp.get("item_ref") or inp.get("ref") or inp.get("item_id") - if item_ref and tool_name in ("get_plan_item", "update_plan_item", - "delete_plan_item", "move_plan_item"): + if item_ref and tool_name in ( + "get_plan_item", + "update_plan_item", + "delete_plan_item", + "move_plan_item", + ): if self.context_store: item = self.context_store.resolve_plan_item(str(item_ref), campaign_id=campaign_id) if item: @@ -806,8 +844,9 @@ def tool_label(self, tool_name: str, tool_input: Dict) -> str: return "" - async def _execute_single_tool(self, tool_name: str, tool_input: Dict, - context: Optional[Dict] = None) -> str: + async def _execute_single_tool( + self, tool_name: str, tool_input: dict, context: dict | None = None + ) -> str: """Execute a single tool call using the tool registry. Parameters @@ -827,13 +866,13 @@ async def _execute_single_tool(self, tool_name: str, tool_input: Dict, def _track_token_usage(self, response): """Track token usage from API response, including cache metrics.""" - if hasattr(response, 'usage'): + if hasattr(response, "usage"): usage = response.usage self.total_input_tokens += usage.input_tokens self.total_output_tokens += usage.output_tokens self.api_call_count += 1 - self.cache_creation_tokens += getattr(usage, 'cache_creation_input_tokens', 0) - self.cache_read_tokens += getattr(usage, 'cache_read_input_tokens', 0) + self.cache_creation_tokens += getattr(usage, "cache_creation_input_tokens", 0) + self.cache_read_tokens += getattr(usage, "cache_read_input_tokens", 0) @property def current_context_tokens(self) -> int: @@ -844,14 +883,14 @@ def current_context_tokens(self) -> int: conv_chars = 0 for msg in self.conversation_history: - content = msg.get('content', '') + content = msg.get("content", "") if isinstance(content, str): conv_chars += len(content) elif isinstance(content, list): for block in content: if isinstance(block, dict): - conv_chars += len(str(block.get('text', ''))) - elif hasattr(block, 'text'): + conv_chars += len(str(block.get("text", ""))) + elif hasattr(block, "text"): conv_chars += len(str(block.text)) else: conv_chars += len(str(block)) @@ -917,19 +956,22 @@ async def _call_api_with_retry(self, api_func, *args, max_retries=3, **kwargs): try: return await asyncio.to_thread(api_func, *args, **kwargs) except APIStatusError as e: - error_type = getattr(e, 'body', {}) + error_type = getattr(e, "body", {}) if isinstance(error_type, dict): - error_type = error_type.get('error', {}).get('type', '') + error_type = error_type.get("error", {}).get("type", "") is_retryable = ( - error_type in ('overloaded_error', 'rate_limit_error') or - 'overloaded' in str(e).lower() or - 'rate_limit' in str(e).lower() + error_type in ("overloaded_error", "rate_limit_error") + or "overloaded" in str(e).lower() + or "rate_limit" in str(e).lower() ) if is_retryable and attempt < max_retries - 1: - wait_time = retry_delay * (2 ** attempt) - logger.warning(f"API error ({error_type}), retrying in {wait_time:.1f}s (attempt {attempt + 1}/{max_retries})") + wait_time = retry_delay * (2**attempt) + logger.warning( + f"API error ({error_type}), retrying in {wait_time:.1f}s " + f"(attempt {attempt + 1}/{max_retries})" + ) await asyncio.sleep(wait_time) continue diff --git a/gently/harness/detection/verifier.py b/gently/harness/detection/verifier.py index 849fbdbf..1a714bcd 100644 --- a/gently/harness/detection/verifier.py +++ b/gently/harness/detection/verifier.py @@ -14,14 +14,15 @@ import logging from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Callable, Dict, List, Optional +from typing import Any import anthropic +from gently.core import EventType, get_event_bus from gently.settings import settings -from .detector import Detector, DetectionResult, ConfidenceLevel + from ..state import EmbryoState -from gently.core import EventType, get_event_bus +from .detector import ConfidenceLevel, DetectionResult, Detector logger = logging.getLogger(__name__) @@ -38,12 +39,22 @@ # many independent votes rather than introspected by one call. _ADVERSARIAL_TOOL = { "name": "record_adversarial_review", - "description": "Record the critical review verdict: whether counter-evidence against the detection was found.", + "description": ( + "Record the critical review verdict: whether counter-evidence " + "against the detection was found." + ), "input_schema": { "type": "object", "properties": { - "found_counter_evidence": {"type": "boolean", "description": "True only if there is real evidence the detection is wrong."}, - "concerns": {"type": "array", "items": {"type": "string"}, "description": "Specific doubts or alternative explanations; empty list if none."}, + "found_counter_evidence": { + "type": "boolean", + "description": "True only if there is real evidence the detection is wrong.", + }, + "concerns": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific doubts or alternative explanations; empty list if none.", + }, }, "required": ["found_counter_evidence", "concerns"], }, @@ -51,12 +62,20 @@ _INDEPENDENT_TOOL = { "name": "record_independent_assessment", - "description": "Record an unbiased fresh assessment of whether the event occurred in this image.", + "description": ( + "Record an unbiased fresh assessment of whether the event occurred in this image." + ), "input_schema": { "type": "object", "properties": { - "detected": {"type": "boolean", "description": "True if the event is observed in this image."}, - "key_evidence": {"type": "string", "description": "What specifically supports the conclusion."}, + "detected": { + "type": "boolean", + "description": "True if the event is observed in this image.", + }, + "key_evidence": { + "type": "string", + "description": "What specifically supports the conclusion.", + }, }, "required": ["detected", "key_evidence"], }, @@ -64,12 +83,23 @@ _TEMPORAL_TOOL = { "name": "record_temporal_comparison", - "description": "Record whether a real change consistent with the event occurred between the previous and current frames.", + "description": ( + "Record whether a real change consistent with the event occurred " + "between the previous and current frames." + ), "input_schema": { "type": "object", "properties": { - "change_detected": {"type": "boolean", "description": "True if a clear change consistent with the event is visible across frames."}, - "description": {"type": "string", "description": "The specific change observed between previous and current frames."}, + "change_detected": { + "type": "boolean", + "description": ( + "True if a clear change consistent with the event is visible across frames." + ), + }, + "description": { + "type": "string", + "description": "The specific change observed between previous and current frames.", + }, }, "required": ["change_detected", "description"], }, @@ -81,8 +111,18 @@ "input_schema": { "type": "object", "properties": { - "suspicious": {"type": "boolean", "description": "True if hardware errors could have affected image quality or positioning for this embryo."}, - "concerns": {"type": "array", "items": {"type": "string"}, "description": "Specific hardware concerns; empty list if none."}, + "suspicious": { + "type": "boolean", + "description": ( + "True if hardware errors could have affected image quality " + "or positioning for this embryo." + ), + }, + "concerns": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific hardware concerns; empty list if none.", + }, "reasoning": {"type": "string", "description": "Brief explanation of the analysis."}, }, "required": ["suspicious", "concerns", "reasoning"], @@ -90,7 +130,7 @@ } -def _tool_input(response) -> Optional[Dict[str, Any]]: +def _tool_input(response) -> dict[str, Any] | None: """Return the parsed input of the first tool_use block, or None.""" for block in getattr(response, "content", None) or []: if getattr(block, "type", None) == "tool_use": @@ -101,14 +141,16 @@ def _tool_input(response) -> Optional[Dict[str, Any]]: @dataclass class AdversarialResult: """Result of adversarial verification strategy""" + found_counter_evidence: bool - concerns: List[str] + concerns: list[str] raw_response: str @dataclass class IndependentResult: """Result of independent verification strategy""" + detected: bool key_evidence: str raw_response: str @@ -117,6 +159,7 @@ class IndependentResult: @dataclass class TemporalResult: """Result of temporal comparison strategy""" + change_detected: bool description: str raw_response: str @@ -125,19 +168,21 @@ class TemporalResult: @dataclass class EnsembleResult: """Result of ensemble voting strategy""" + votes_yes: int votes_no: int total_votes: int agreement_ratio: float # votes_yes / total_votes if detected, votes_no / total_votes if not consensus_detected: bool # True if >70% agree on YES - raw_responses: List[str] = field(default_factory=list) + raw_responses: list[str] = field(default_factory=list) @dataclass class HardwareContextResult: """Result of hardware context analysis strategy""" + suspicious: bool # True if hardware errors could have caused false positive - concerns: List[str] # Specific concerns identified + concerns: list[str] # Specific concerns identified reasoning: str raw_response: str @@ -145,15 +190,16 @@ class HardwareContextResult: @dataclass class VerificationResult: """Combined result of all verification strategies""" + original_detected: bool - original_confidence: Optional[ConfidenceLevel] + original_confidence: ConfidenceLevel | None # Strategy results adversarial: AdversarialResult independent: IndependentResult temporal: TemporalResult - ensemble: Optional[EnsembleResult] = None # Only for hatching detection - hardware_context: Optional[HardwareContextResult] = None # Only when errors present + ensemble: EnsembleResult | None = None # Only for hatching detection + hardware_context: HardwareContextResult | None = None # Only when errors present # Consensus consensus: bool = False @@ -163,41 +209,43 @@ class VerificationResult: timestamp: datetime = field(default_factory=datetime.now) verification_duration_seconds: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serialize to dictionary""" result = { - 'original_detected': self.original_detected, - 'original_confidence': self.original_confidence.value if self.original_confidence else None, - 'adversarial': { - 'found_counter_evidence': self.adversarial.found_counter_evidence, - 'concerns': self.adversarial.concerns, + "original_detected": self.original_detected, + "original_confidence": self.original_confidence.value + if self.original_confidence + else None, + "adversarial": { + "found_counter_evidence": self.adversarial.found_counter_evidence, + "concerns": self.adversarial.concerns, }, - 'independent': { - 'detected': self.independent.detected, - 'key_evidence': self.independent.key_evidence, + "independent": { + "detected": self.independent.detected, + "key_evidence": self.independent.key_evidence, }, - 'temporal': { - 'change_detected': self.temporal.change_detected, - 'description': self.temporal.description, + "temporal": { + "change_detected": self.temporal.change_detected, + "description": self.temporal.description, }, - 'consensus': self.consensus, - 'consensus_reasoning': self.consensus_reasoning, - 'timestamp': self.timestamp.isoformat(), - 'verification_duration_seconds': self.verification_duration_seconds, + "consensus": self.consensus, + "consensus_reasoning": self.consensus_reasoning, + "timestamp": self.timestamp.isoformat(), + "verification_duration_seconds": self.verification_duration_seconds, } if self.ensemble: - result['ensemble'] = { - 'votes_yes': self.ensemble.votes_yes, - 'votes_no': self.ensemble.votes_no, - 'total_votes': self.ensemble.total_votes, - 'agreement_ratio': self.ensemble.agreement_ratio, - 'consensus_detected': self.ensemble.consensus_detected, + result["ensemble"] = { + "votes_yes": self.ensemble.votes_yes, + "votes_no": self.ensemble.votes_no, + "total_votes": self.ensemble.total_votes, + "agreement_ratio": self.ensemble.agreement_ratio, + "consensus_detected": self.ensemble.consensus_detected, } if self.hardware_context: - result['hardware_context'] = { - 'suspicious': self.hardware_context.suspicious, - 'concerns': self.hardware_context.concerns, - 'reasoning': self.hardware_context.reasoning, + result["hardware_context"] = { + "suspicious": self.hardware_context.suspicious, + "concerns": self.hardware_context.concerns, + "reasoning": self.hardware_context.reasoning, } return result @@ -242,7 +290,7 @@ def __init__( self.ensemble_threshold = ensemble_threshold self._event_bus = event_bus or get_event_bus() - def _emit_event(self, event_type: EventType, data: Dict): + def _emit_event(self, event_type: EventType, data: dict): """Emit event to viz server""" if self._event_bus: self._event_bus.publish(event_type, data) @@ -276,19 +324,13 @@ async def verify( start_time = datetime.now() # Run all strategies in parallel for speed - adversarial_task = self._run_adversarial( - detector, embryo_state, original_result, timepoint - ) - independent_task = self._run_independent( - detector, embryo_state, timepoint - ) - temporal_task = self._run_temporal_check( - detector, embryo_state, timepoint - ) + adversarial_task = self._run_adversarial(detector, embryo_state, original_result, timepoint) + independent_task = self._run_independent(detector, embryo_state, timepoint) + temporal_task = self._run_temporal_check(detector, embryo_state, timepoint) # For hatching detection, also run ensemble voting ensemble_result = None - if detector.name == 'hatching': + if detector.name == "hatching": ensemble_task = self._run_ensemble_hatching(embryo_state) adversarial, independent, temporal, ensemble_result = await asyncio.gather( adversarial_task, independent_task, temporal_task, ensemble_task @@ -320,7 +362,11 @@ async def verify( logger.info( f"Verification complete for {detector.name}: " f"consensus={consensus}, duration={duration:.2f}s" - + (f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" if ensemble_result else "") + + ( + f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" + if ensemble_result + else "" + ) ) return result @@ -359,21 +405,15 @@ async def verify_with_context( start_time = datetime.now() # Run all strategies in parallel for speed - adversarial_task = self._run_adversarial( - detector, embryo_state, original_result, timepoint - ) - independent_task = self._run_independent( - detector, embryo_state, timepoint - ) - temporal_task = self._run_temporal_check( - detector, embryo_state, timepoint - ) + adversarial_task = self._run_adversarial(detector, embryo_state, original_result, timepoint) + independent_task = self._run_independent(detector, embryo_state, timepoint) + temporal_task = self._run_temporal_check(detector, embryo_state, timepoint) # For hatching detection, also run ensemble voting ensemble_result = None hardware_result = None - if detector.name == 'hatching': + if detector.name == "hatching": ensemble_task = self._run_ensemble_hatching(embryo_state) # Run hardware context analysis if there are errors @@ -381,7 +421,13 @@ async def verify_with_context( hardware_task = self._run_hardware_context_analysis( global_error_context, embryo_state.id ) - adversarial, independent, temporal, ensemble_result, hardware_result = await asyncio.gather( + ( + adversarial, + independent, + temporal, + ensemble_result, + hardware_result, + ) = await asyncio.gather( adversarial_task, independent_task, temporal_task, ensemble_task, hardware_task ) else: @@ -400,84 +446,133 @@ async def verify_with_context( # Adversarial result strategies_complete += 1 - self._emit_event(EventType.VERIFICATION_STRATEGY, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'strategy': 'adversarial', - 'passed': not adversarial.found_counter_evidence, - 'summary': f"Counter-evidence: {'YES - ' + ', '.join(adversarial.concerns) if adversarial.found_counter_evidence else 'None found'}", - }) - self._emit_event(EventType.VERIFICATION_PROGRESS, { - 'embryo_id': embryo_id, - 'strategies_complete': strategies_complete, - 'total_strategies': total_strategies, - }) + adversarial_summary = ( + "YES - " + ", ".join(adversarial.concerns) + if adversarial.found_counter_evidence + else "None found" + ) + self._emit_event( + EventType.VERIFICATION_STRATEGY, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "strategy": "adversarial", + "passed": not adversarial.found_counter_evidence, + "summary": f"Counter-evidence: {adversarial_summary}", + }, + ) + self._emit_event( + EventType.VERIFICATION_PROGRESS, + { + "embryo_id": embryo_id, + "strategies_complete": strategies_complete, + "total_strategies": total_strategies, + }, + ) # Independent result strategies_complete += 1 - self._emit_event(EventType.VERIFICATION_STRATEGY, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'strategy': 'independent', - 'passed': independent.detected, - 'summary': f"Independent detection: {'YES' if independent.detected else 'NO'} - {independent.key_evidence}", - }) - self._emit_event(EventType.VERIFICATION_PROGRESS, { - 'embryo_id': embryo_id, - 'strategies_complete': strategies_complete, - 'total_strategies': total_strategies, - }) + self._emit_event( + EventType.VERIFICATION_STRATEGY, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "strategy": "independent", + "passed": independent.detected, + "summary": ( + f"Independent detection: {'YES' if independent.detected else 'NO'}" + f" - {independent.key_evidence}" + ), + }, + ) + self._emit_event( + EventType.VERIFICATION_PROGRESS, + { + "embryo_id": embryo_id, + "strategies_complete": strategies_complete, + "total_strategies": total_strategies, + }, + ) # Temporal result strategies_complete += 1 - self._emit_event(EventType.VERIFICATION_STRATEGY, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'strategy': 'temporal', - 'passed': temporal.change_detected, - 'summary': f"Change detected: {'YES' if temporal.change_detected else 'NO'} - {temporal.description}", - }) - self._emit_event(EventType.VERIFICATION_PROGRESS, { - 'embryo_id': embryo_id, - 'strategies_complete': strategies_complete, - 'total_strategies': total_strategies, - }) + self._emit_event( + EventType.VERIFICATION_STRATEGY, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "strategy": "temporal", + "passed": temporal.change_detected, + "summary": ( + f"Change detected: {'YES' if temporal.change_detected else 'NO'}" + f" - {temporal.description}" + ), + }, + ) + self._emit_event( + EventType.VERIFICATION_PROGRESS, + { + "embryo_id": embryo_id, + "strategies_complete": strategies_complete, + "total_strategies": total_strategies, + }, + ) # Ensemble result (if applicable) if ensemble_result: strategies_complete += 1 - self._emit_event(EventType.VERIFICATION_STRATEGY, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'strategy': 'ensemble', - 'passed': ensemble_result.consensus_detected, - 'summary': f"Ensemble vote: {ensemble_result.votes_yes}/{ensemble_result.total_votes} YES ({ensemble_result.agreement_ratio*100:.0f}%)", - 'votes_yes': ensemble_result.votes_yes, - 'votes_no': ensemble_result.votes_no, - 'total_votes': ensemble_result.total_votes, - }) - self._emit_event(EventType.VERIFICATION_PROGRESS, { - 'embryo_id': embryo_id, - 'strategies_complete': strategies_complete, - 'total_strategies': total_strategies, - }) + self._emit_event( + EventType.VERIFICATION_STRATEGY, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "strategy": "ensemble", + "passed": ensemble_result.consensus_detected, + "summary": ( + f"Ensemble vote: {ensemble_result.votes_yes}/{ensemble_result.total_votes}" + f" YES ({ensemble_result.agreement_ratio * 100:.0f}%)" + ), + "votes_yes": ensemble_result.votes_yes, + "votes_no": ensemble_result.votes_no, + "total_votes": ensemble_result.total_votes, + }, + ) + self._emit_event( + EventType.VERIFICATION_PROGRESS, + { + "embryo_id": embryo_id, + "strategies_complete": strategies_complete, + "total_strategies": total_strategies, + }, + ) # Hardware context result (if applicable) if hardware_result: strategies_complete += 1 - self._emit_event(EventType.VERIFICATION_STRATEGY, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'strategy': 'hardware_context', - 'passed': not hardware_result.suspicious, - 'summary': f"Hardware errors suspicious: {'YES - ' + ', '.join(hardware_result.concerns) if hardware_result.suspicious else 'No'}", - 'reasoning': hardware_result.reasoning, - }) - self._emit_event(EventType.VERIFICATION_PROGRESS, { - 'embryo_id': embryo_id, - 'strategies_complete': strategies_complete, - 'total_strategies': total_strategies, - }) + hardware_summary = ( + "YES - " + ", ".join(hardware_result.concerns) + if hardware_result.suspicious + else "No" + ) + self._emit_event( + EventType.VERIFICATION_STRATEGY, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "strategy": "hardware_context", + "passed": not hardware_result.suspicious, + "summary": f"Hardware errors suspicious: {hardware_summary}", + "reasoning": hardware_result.reasoning, + }, + ) + self._emit_event( + EventType.VERIFICATION_PROGRESS, + { + "embryo_id": embryo_id, + "strategies_complete": strategies_complete, + "total_strategies": total_strategies, + }, + ) # Determine consensus (with hardware context) consensus, reasoning = self._evaluate_consensus_with_hardware( @@ -502,26 +597,37 @@ async def verify_with_context( logger.info( f"Verification (with context) complete for {detector.name}: " f"consensus={consensus}, duration={duration:.2f}s" - + (f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" if ensemble_result else "") + + ( + f", ensemble={ensemble_result.votes_yes}/{ensemble_result.total_votes} YES" + if ensemble_result + else "" + ) + (f", hardware_suspicious={hardware_result.suspicious}" if hardware_result else "") ) # Emit VERIFICATION_COMPLETED event with full summary - self._emit_event(EventType.VERIFICATION_COMPLETED, { - 'embryo_id': embryo_id, - 'detector_name': detector.name, - 'consensus': consensus, - 'reasoning': reasoning, - 'duration_seconds': duration, - 'strategies': { - 'adversarial': not adversarial.found_counter_evidence, - 'independent': independent.detected, - 'temporal': temporal.change_detected, - 'ensemble': ensemble_result.consensus_detected if ensemble_result else None, - 'hardware_context': (not hardware_result.suspicious) if hardware_result else None, + self._emit_event( + EventType.VERIFICATION_COMPLETED, + { + "embryo_id": embryo_id, + "detector_name": detector.name, + "consensus": consensus, + "reasoning": reasoning, + "duration_seconds": duration, + "strategies": { + "adversarial": not adversarial.found_counter_evidence, + "independent": independent.detected, + "temporal": temporal.change_detected, + "ensemble": ensemble_result.consensus_detected if ensemble_result else None, + "hardware_context": (not hardware_result.suspicious) + if hardware_result + else None, + }, + "ensemble_votes": f"{ensemble_result.votes_yes}/{ensemble_result.total_votes}" + if ensemble_result + else None, }, - 'ensemble_votes': f"{ensemble_result.votes_yes}/{ensemble_result.total_votes}" if ensemble_result else None, - }) + ) return result @@ -549,7 +655,8 @@ async def _run_hardware_context_analysis( Analysis result """ try: - prompt = f"""You are analyzing hardware error context for a microscopy detection verification. + prompt = f"""\ +You are analyzing hardware error context for a microscopy detection verification. GLOBAL ERROR LOG: {global_error_context} @@ -562,10 +669,12 @@ async def _run_hardware_context_analysis( - Stage positioning errors could cause wrong embryo to be imaged - Acquisition timeouts could cause partial/blank images (blank images look like empty FOV = hatched) - Camera errors could produce corrupted data -- Errors on OTHER embryos in the same round could indicate systemic issues (stage drift, hardware instability) +- Errors on OTHER embryos in the same round could indicate systemic issues + (stage drift, hardware instability) - Multiple errors in quick succession suggests hardware problems -If ANY errors occurred that could have affected the image quality or positioning for {embryo_id}, mark it suspicious. +If ANY errors occurred that could have affected the image quality or positioning +for {embryo_id}, mark it suspicious. Record your analysis with the record_hardware_context tool. """ @@ -576,7 +685,7 @@ async def _run_hardware_context_analysis( max_tokens=300, tools=[_HARDWARE_CONTEXT_TOOL], tool_choice={"type": "tool", "name": _HARDWARE_CONTEXT_TOOL["name"]}, - messages=[{"role": "user", "content": prompt}] + messages=[{"role": "user", "content": prompt}], ) data = _tool_input(response) @@ -605,8 +714,8 @@ def _evaluate_consensus_with_hardware( adversarial: AdversarialResult, independent: IndependentResult, temporal: TemporalResult, - ensemble: Optional[EnsembleResult] = None, - hardware_context: Optional[HardwareContextResult] = None, + ensemble: EnsembleResult | None = None, + hardware_context: HardwareContextResult | None = None, ) -> tuple[bool, str]: """ Evaluate consensus across all verification strategies including hardware context. @@ -622,7 +731,9 @@ def _evaluate_consensus_with_hardware( if not adversarial.found_counter_evidence: agreements += 1 else: - disagreements.append(f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}") + disagreements.append( + f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}" + ) # Check independent: should also detect if independent.detected: @@ -662,12 +773,19 @@ def _evaluate_consensus_with_hardware( consensus = agreements == total_strategies if consensus: - parts = ["no counter-evidence found", "independent analysis confirms", "temporal change observed"] + parts = [ + "no counter-evidence found", + "independent analysis confirms", + "temporal change observed", + ] if ensemble: parts.append(f"ensemble confirms ({ensemble.votes_yes}/{ensemble.total_votes} YES)") if hardware_context: parts.append("no hardware error concerns") - reasoning = f"All verification strategies agree ({total_strategies}/{total_strategies}): " + ", ".join(parts) + reasoning = ( + f"All verification strategies agree ({total_strategies}/{total_strategies}): " + + ", ".join(parts) + ) else: reasoning = ( f"Verification disagreement ({agreements}/{total_strategies} agree): " @@ -700,7 +818,7 @@ async def _run_adversarial( ) # Build detector-specific critical review guidance - if detector.name == 'hatching': + if detector.name == "hatching": specific_guidance = """ For HATCHING specifically, look for these common FALSE POSITIVE patterns: - Is the worm still COILED/PRETZEL-SHAPED inside the eggshell? @@ -713,10 +831,11 @@ async def _run_adversarial( else: specific_guidance = "" - prompt = f"""You are reviewing a detection result for a C. elegans embryo (diSPIM max projection). + prompt = f"""\ +You are reviewing a detection result for a C. elegans embryo (diSPIM max projection). The system detected: {detector.name} -Original reasoning: {original_result.reasoning or 'not provided'} +Original reasoning: {original_result.reasoning or "not provided"} NOW ACT AS A CRITICAL REVIEWER. Your job is to find reasons why this detection might be INCORRECT: - Could this be a false positive? @@ -735,7 +854,7 @@ async def _run_adversarial( max_tokens=500, tools=[_ADVERSARIAL_TOOL], tool_choice={"type": "tool", "name": _ADVERSARIAL_TOOL["name"]}, - messages=[{"role": "user", "content": content}] + messages=[{"role": "user", "content": content}], ) data = _tool_input(response) @@ -779,7 +898,7 @@ async def _run_independent( ) # Build detector-specific criteria - if detector.name == 'hatching': + if detector.name == "hatching": criteria = """ TRUE HATCHING criteria (must meet at least one): - Worm body is OUTSIDE the eggshell boundary (free-floating, elongated) @@ -793,7 +912,8 @@ async def _run_independent( criteria = detector.description # Use a neutral prompt that doesn't reveal the previous detection - prompt = f"""Analyze this C. elegans embryo image (diSPIM max projection) at timepoint {timepoint}. + prompt = f"""\ +Analyze this C. elegans embryo image (diSPIM max projection) at timepoint {timepoint}. Question: Has '{detector.name}' occurred in this embryo? @@ -813,7 +933,7 @@ async def _run_independent( max_tokens=400, tools=[_INDEPENDENT_TOOL], tool_choice={"type": "tool", "name": _INDEPENDENT_TOOL["name"]}, - messages=[{"role": "user", "content": content}] + messages=[{"role": "user", "content": content}], ) data = _tool_input(response) @@ -861,14 +981,16 @@ async def _run_temporal_check( prev_images = [] for img in embryo_state.recent_images[-3:-1]: if img.max_projection_b64: - prev_images.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": img.max_projection_b64, + prev_images.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": img.max_projection_b64, + }, } - }) + ) if not prev_images: return TemporalResult( @@ -878,7 +1000,7 @@ async def _run_temporal_check( ) # Build detector-specific temporal criteria - if detector.name == 'hatching': + if detector.name == "hatching": temporal_criteria = """For HATCHING, look for: - A visible BREACH appearing in the eggshell boundary (not just expansion) - The worm physically EXITING the shell (part of body moves outside) @@ -890,10 +1012,11 @@ async def _run_temporal_check( - Not just a static state that could have existed before - Clear evidence of progression or event occurrence""" - prompt = f"""Compare these sequential timepoints of a C. elegans embryo (diSPIM max projection). + prompt = f"""\ +Compare these sequential timepoints of a C. elegans embryo (diSPIM max projection). PREVIOUS TIMEPOINTS (shown first): -These are from t={timepoint-2} to t={timepoint-1} +These are from t={timepoint - 2} to t={timepoint - 1} CURRENT TIMEPOINT (shown last): This is t={timepoint} @@ -914,7 +1037,7 @@ async def _run_temporal_check( max_tokens=400, tools=[_TEMPORAL_TOOL], tool_choice={"type": "tool", "name": _TEMPORAL_TOOL["name"]}, - messages=[{"role": "user", "content": content}] + messages=[{"role": "user", "content": content}], ) data = _tool_input(response) @@ -934,25 +1057,23 @@ async def _run_temporal_check( raw_response="", ) - def _get_image_content( - self, - embryo_state: EmbryoState, - num_images: int = 1 - ) -> List[Dict]: + def _get_image_content(self, embryo_state: EmbryoState, num_images: int = 1) -> list[dict]: """Get image content blocks for Claude API""" images = [] recent = embryo_state.recent_images[-num_images:] if embryo_state.recent_images else [] for img in recent: if img.max_projection_b64: - images.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": img.max_projection_b64, + images.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": img.max_projection_b64, + }, } - }) + ) return images @@ -986,8 +1107,10 @@ async def _run_ensemble_hatching(self, embryo_state: EmbryoState) -> EnsembleRes Answer ONE question: Has the embryo HATCHED? -HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, or field is empty because worm left). -NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, even if shell looks expanded). +HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, +or field is empty because worm left). +NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, +even if shell looks expanded). Respond with ONLY: YES or NO""" @@ -999,7 +1122,7 @@ async def single_vote() -> str: self.claude.messages.create, model=self.ensemble_model, max_tokens=10, # Very short response expected - messages=[{"role": "user", "content": content}] + messages=[{"role": "user", "content": content}], ) return response.content[0].text.strip().upper() except Exception as e: @@ -1007,7 +1130,10 @@ async def single_vote() -> str: return "ERROR" # Run all votes in parallel - logger.info(f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls for hatching verification") + logger.info( + f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls " + "for hatching verification" + ) tasks = [single_vote() for _ in range(self.ensemble_size)] responses = await asyncio.gather(*tasks) @@ -1062,7 +1188,7 @@ def _evaluate_consensus( adversarial: AdversarialResult, independent: IndependentResult, temporal: TemporalResult, - ensemble: Optional[EnsembleResult] = None, + ensemble: EnsembleResult | None = None, ) -> tuple[bool, str]: """ Evaluate consensus across all verification strategies. @@ -1078,7 +1204,9 @@ def _evaluate_consensus( if not adversarial.found_counter_evidence: agreements += 1 else: - disagreements.append(f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}") + disagreements.append( + f"Adversarial found counter-evidence: {', '.join(adversarial.concerns[:2])}" + ) # Check independent: should also detect if independent.detected: @@ -1112,13 +1240,14 @@ def _evaluate_consensus( f"All verification strategies agree ({total_strategies}/{total_strategies}): " f"no counter-evidence found, independent analysis confirms detection, " f"temporal change observed, ensemble voting confirms " - f"({ensemble.votes_yes}/{ensemble.total_votes} = {ensemble.agreement_ratio:.0%} YES)." + f"({ensemble.votes_yes}/{ensemble.total_votes} = " + f"{ensemble.agreement_ratio:.0%} YES)." ) else: reasoning = ( - f"All verification strategies agree: " - f"no counter-evidence found, independent analysis confirms detection, " - f"temporal change observed." + "All verification strategies agree: " + "no counter-evidence found, independent analysis confirms detection, " + "temporal change observed." ) else: reasoning = ( diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 3672ba88..6a8c5eb3 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1912,7 +1912,8 @@ def _notify_context_change(self, kind: str = "context") -> None: """Emit CONTEXT_UPDATED on the global bus so the shared-visibility surface refreshes live. Best-effort — a bus failure never breaks a write.""" try: - from gently.core.event_bus import emit, EventType + from gently.core.event_bus import EventType, emit + emit(EventType.CONTEXT_UPDATED, {"kind": kind}, source="context_store") except Exception: pass diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index 58c8ee84..3757a0fe 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -243,7 +243,7 @@ class ImagingSpec: # Per-field provenance for INFERRED values — field name -> {source, confidence}. # e.g. {"laser_wavelength_nm": {"source": "inferred:genotype", "confidence": "medium"}} # Lets the UI tag each value with where it came from and what to confirm. - provenance: Dict[str, Dict[str, str]] = field(default_factory=dict) + provenance: dict[str, dict[str, str]] = field(default_factory=dict) @dataclass diff --git a/gently/settings.py b/gently/settings.py index 33254621..37ea4336 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -4,6 +4,7 @@ All configurable values live here. Override via environment variables prefixed with GENTLY_ (e.g., GENTLY_VIZ_PORT=9090). """ + import os from dataclasses import dataclass, field from pathlib import Path @@ -29,6 +30,7 @@ def _env(key: str, default): @dataclass(frozen=True) class NetworkSettings: """Ports, hosts, and bind addresses.""" + viz_port: int = field(default_factory=lambda: _env("VIZ_PORT", 8080)) viz_host: str = field(default_factory=lambda: _env("VIZ_HOST", "0.0.0.0")) device_port: int = field(default_factory=lambda: _env("DEVICE_PORT", 60610)) @@ -40,7 +42,10 @@ class NetworkSettings: @dataclass(frozen=True) class MeshSettings: """Mesh networking parameters.""" - broadcast_interval_s: float = field(default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0)) + + broadcast_interval_s: float = field( + default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0) + ) replay_window_s: float = field(default_factory=lambda: _env("MESH_REPLAY_WINDOW", 30.0)) reaper_interval_s: float = field(default_factory=lambda: _env("MESH_REAPER_INTERVAL", 10.0)) status_refresh_s: float = field(default_factory=lambda: _env("MESH_STATUS_REFRESH", 30.0)) @@ -72,6 +77,7 @@ class ModelSettings: Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere (4.6+ family rejects them). """ + main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-fable-5")) perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-8")) fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-sonnet-4-6")) @@ -79,12 +85,15 @@ class ModelSettings: # When the main tier (Fable 5) declines a turn (stop_reason="refusal"), the # main-tier calls transparently retry it on this model instead of surfacing # the refusal. Empty disables the fallback. - refusal_fallback: str = field(default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8")) + refusal_fallback: str = field( + default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8") + ) @dataclass(frozen=True) class StorageSettings: """File paths for data storage.""" + base_path: Path = field(default_factory=lambda: _env("STORAGE_PATH", Path("D:/Gently3"))) @property @@ -99,6 +108,7 @@ def traces_dir(self) -> Path: @dataclass(frozen=True) class TimeoutSettings: """Timeout values in seconds.""" + plan_execution: int = field(default_factory=lambda: _env("TIMEOUT_PLAN", 300)) rpc_call: int = field(default_factory=lambda: _env("TIMEOUT_RPC", 60)) volume_acquisition: int = field(default_factory=lambda: _env("TIMEOUT_VOLUME", 15)) @@ -108,6 +118,7 @@ class TimeoutSettings: @dataclass(frozen=True) class ApiSettings: """External API configuration.""" + ncbi_tool: str = field(default_factory=lambda: _env("NCBI_TOOL", "gently")) ncbi_email: str = field(default_factory=lambda: _env("NCBI_EMAIL", "pskeshu@gmail.com")) @@ -115,6 +126,7 @@ class ApiSettings: @dataclass(frozen=True) class MlSettings: """Machine learning training parameters.""" + model_cache_dir: Path = field(default_factory=lambda: _env("ML_MODEL_CACHE", Path("models"))) default_batch_size: int = field(default_factory=lambda: _env("ML_BATCH_SIZE", 32)) default_epochs: int = field(default_factory=lambda: _env("ML_EPOCHS", 50)) @@ -124,14 +136,18 @@ class MlSettings: @dataclass(frozen=True) class TransferSettings: """Bulk transfer protocol parameters.""" + transfer_port: int = field(default_factory=lambda: _env("TRANSFER_PORT", 19548)) chunk_size: int = field(default_factory=lambda: _env("TRANSFER_CHUNK_SIZE", 1048576)) # 1MB - max_concurrent_transfers: int = field(default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4)) + max_concurrent_transfers: int = field( + default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4) + ) @dataclass(frozen=True) class UISettings: """Web UI feature flags.""" + # New agent-first UX paradigm (welcome→shell unfold, dual-rendered agent # asks, inference-first plan mode, shared-visibility surface). Now ON by # default; the v1 dashboard remains available as a fallback via @@ -142,6 +158,7 @@ class UISettings: @dataclass(frozen=True) class Settings: """Top-level settings container.""" + network: NetworkSettings = field(default_factory=NetworkSettings) mesh: MeshSettings = field(default_factory=MeshSettings) models: ModelSettings = field(default_factory=ModelSettings) diff --git a/gently/ui/web/connection_manager.py b/gently/ui/web/connection_manager.py index 00aeaef2..1d1f2a23 100644 --- a/gently/ui/web/connection_manager.py +++ b/gently/ui/web/connection_manager.py @@ -161,7 +161,9 @@ async def broadcast(self, message: dict): # Expected when a client disconnects/reloads mid-broadcast # (send after websocket.close). The connection is dropped # below, so this is debug-level, not a warning. - logger.debug("Dropping a websocket that errored on send (client likely gone): %s", e) + logger.debug( + "Dropping a websocket that errored on send (client likely gone): %s", e + ) disconnected.append(connection) # Remove disconnected clients diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py index 1fffcb7f..e07d4501 100644 --- a/gently/ui/web/routes/chat.py +++ b/gently/ui/web/routes/chat.py @@ -19,8 +19,8 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel -from gently.ui.web.auth import require_control from gently.settings import settings +from gently.ui.web.auth import require_control logger = logging.getLogger(__name__) diff --git a/gently/ui/web/routes/context.py b/gently/ui/web/routes/context.py index 2d7c39df..66c7f25c 100644 --- a/gently/ui/web/routes/context.py +++ b/gently/ui/web/routes/context.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Body, Depends from gently.ui.web.auth import require_control + from .campaigns import _serialize @@ -36,8 +37,7 @@ async def get_context(): except Exception: return empty - @router.post("/api/context/questions/{q_id}/resolve", - dependencies=[Depends(require_control)]) + @router.post("/api/context/questions/{q_id}/resolve", dependencies=[Depends(require_control)]) async def resolve_question(q_id: str, resolution: str = Body("", embed=True)): cs = _store() if cs is None: @@ -45,8 +45,9 @@ async def resolve_question(q_id: str, resolution: str = Body("", embed=True)): cs.resolve_question(q_id, resolution or "") return {"ok": True} - @router.post("/api/context/watchpoints/{wp_id}/resolve", - dependencies=[Depends(require_control)]) + @router.post( + "/api/context/watchpoints/{wp_id}/resolve", dependencies=[Depends(require_control)] + ) async def resolve_watchpoint(wp_id: str): cs = _store() if cs is None: @@ -54,13 +55,15 @@ async def resolve_watchpoint(wp_id: str): cs.resolve_watchpoint(wp_id) return {"ok": True} - @router.post("/api/context/expectations/{exp_id}/resolve", - dependencies=[Depends(require_control)]) + @router.post( + "/api/context/expectations/{exp_id}/resolve", dependencies=[Depends(require_control)] + ) async def resolve_expectation(exp_id: str, status: str = Body("confirmed", embed=True)): cs = _store() if cs is None: return {"ok": False, "error": "context store unavailable"} from gently.harness.memory.model import ExpectationStatus + try: st = ExpectationStatus(status) except ValueError: From ec64daf6f8a2b46b36bd9d38ba31e38b62f7fc02 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 15 Jun 2026 02:15:48 +0530 Subject: [PATCH 022/200] Models: revert main tier from Fable 5 to Opus 4.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable 5 was declining benign planning turns (stop_reason="refusal"), which tripped the refusal fallback on essentially every turn — adding a full extra round-trip of latency per message. Point MODEL_MAIN at Opus 4.8 so the common path is a single call; refusal_fallback is now inert (the guard skips it when fallback == main). Set MODEL_MAIN=claude-fable-5 to switch back once Fable 5 stops refusing. Prune the now-stale Fable-5 notes from the tier docstring. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/settings.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/gently/settings.py b/gently/settings.py index 37ea4336..9e7d2e68 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -59,12 +59,11 @@ class ModelSettings: """Claude model identifiers — the single source of truth for every tier. Tiers are split by role; capability-first per the latest models: - - main: Claude Fable 5 ($10/$50 per MTok). Per-user-turn reasoning + - tool orchestration (plan mode) and the dopaminergic classifier - stage. Always-on thinking (no thinking budget — control depth - via output_config.effort), ~30%-heavier tokenizer, may refuse - (stop_reason="refusal", empty content); needs ≥30-day org - data retention. + - main: Opus 4.8 ($5/$25). Per-user-turn reasoning + tool + orchestration (plan mode) and the dopaminergic classifier + stage. (Fable 5 was tried here but declined benign planning + turns — stop_reason="refusal" — forcing a fallback on every + turn; set MODEL_MAIN=claude-fable-5 to retry it.) - perception: Opus 4.8 (high-res vision, $5/$25). Highest-frequency tier (per timepoint); Opus-tier vision for perception accuracy. - medium: Opus 4.8. Onboarding / wizard summaries. @@ -72,19 +71,20 @@ class ModelSettings: verifier's parallel ensemble (ensemble_size calls per verification) and blank-image / summary checks. - API note: Fable 5 and Opus 4.8 reject thinking budget_tokens and sampling - params (temperature/top_p/top_k) — adaptive thinking only, depth via effort. - Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere (4.6+ - family rejects them). + API note: Opus 4.8 rejects thinking budget_tokens and sampling params + (temperature/top_p/top_k) — adaptive thinking only, depth via effort. + Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere + (4.6+ family rejects them). """ - main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-fable-5")) + main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-8")) perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-8")) fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-sonnet-4-6")) medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-opus-4-8")) - # When the main tier (Fable 5) declines a turn (stop_reason="refusal"), the - # main-tier calls transparently retry it on this model instead of surfacing - # the refusal. Empty disables the fallback. + # If the main tier declines a turn (stop_reason="refusal"), retry it on this + # model instead of surfacing the refusal. Inert while main is Opus 4.8 (the + # guard skips it when fallback == main); relevant if main is set to Fable 5. + # Empty disables the fallback. refusal_fallback: str = field( default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8") ) From d5dac56bde090c9a9ba9cd21d311b45afaffa31c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 04:28:30 +0530 Subject: [PATCH 023/200] UX v2: expandable tool cards show full (bounded) tool results Stream a bounded result_full alongside the 140-char result_summary so the web UI's expandable tool card can show what a tool actually returned, not just the one-line preview. Frontend (landing.js/css) renders the expandable card; the thinking indicator's label is wrapped in a span so it can be updated live. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/conversation.py | 9 ++ gently/ui/web/static/css/landing.css | 41 ++++- gently/ui/web/static/js/landing.js | 222 +++++++++++++++++++++++---- gently/ui/web/templates/index.html | 2 +- 4 files changed, 241 insertions(+), 33 deletions(-) diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 9085e96b..6ba26262 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -737,12 +737,21 @@ def _run(m): if len(result_summary) > 140: result_summary = result_summary[:139] + "…" + # Full result (bounded) so the UI's expandable tool card can + # show what the tool actually returned — not just the 140-char + # one-liner. The web client caps/scrolls this further; keep the + # streamed payload sane. + result_full = result_text or "" + if len(result_full) > 4000: + result_full = result_full[:4000] + "\n…(truncated)" + yield { "type": "tool_call", "tool_name": block.name, "tool_input": block.input, "duration": time.time() - start_time, "result_summary": result_summary, + "result_full": result_full, "is_error": is_error_flag, } diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css index fff753e8..dad23d0b 100644 --- a/gently/ui/web/static/css/landing.css +++ b/gently/ui/web/static/css/landing.css @@ -292,6 +292,21 @@ body.ux-v2 .home-hero { display: none; } .v2-plan-activity { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } .v2-plan-activity:empty { display: none; margin: 0; } +/* Paginated feed — one agent step (turn) per page, flipped with ‹ Prev / Next ›. + The pager bar / dots reuse .v2-plan-pager / .v2-plan-dots styling. */ +.v2-feed-pages { display: flex; flex-direction: column; } +.v2-act-page { display: none; flex-direction: column; gap: 8px; } +.v2-act-page.active { display: flex; } +/* beat .v2-plan-pager/.v2-plan-dots { display:flex } so [hidden] actually hides */ +.v2-plan-pager[hidden], .v2-plan-dots[hidden] { display: none; } +.v2-feed-pager-bar { margin: 0 0 4px; } +.v2-feed-dots { margin-top: 10px; } +/* the current question, pinned below the paged feed, set off by a divider — + only once there are steps above it (no stray line on the first choice card) */ +#v2-plan-activity:has(.v2-act-page) + .v2-plan-ask:not(:empty) { + margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border, #e4e9f0); +} + /* agent prose between tool calls */ .v2-act-text { font-size: var(--v2-fs-sm); line-height: 1.55; color: var(--text-secondary, #475569); white-space: pre-wrap; } @@ -359,6 +374,29 @@ body.ux-v2[data-theme="dark"] .v2-plan-error { .v2-plan-task::before { content: "·"; color: var(--text-muted, #94a3b8); } .v2-plan-title-row { font-size: var(--v2-fs-sm); font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 8px; } .v2-plan-row.v2-plan-row-freetext .v { font-style: italic; } +.v2-plan-task-empty { color: var(--text-muted, #94a3b8); font-style: italic; } +.v2-plan-task-empty::before { color: transparent; } + +/* THE PLAN pager: ‹ Prev · "Phase · i of N" · Next › + dots, one phase per page */ +.v2-plan-pager { display: flex; align-items: center; gap: 8px; margin: 2px 0 12px; } +.v2-plan-pager-btn { + flex: none; background: none; border: 0; cursor: pointer; font: inherit; + font-size: var(--v2-fs-cap); font-weight: 600; color: var(--accent, #2f6df6); + padding: 5px 7px; border-radius: 7px; transition: background .15s, color .15s, opacity .15s; +} +.v2-plan-pager-btn:hover:not(:disabled) { background: var(--accent-soft); } +.v2-plan-pager-btn:disabled { color: var(--text-muted, #94a3b8); opacity: .45; cursor: default; } +.v2-plan-pager-pos { + flex: 1; min-width: 0; text-align: center; font-size: var(--v2-fs-cap); font-weight: 600; + color: var(--text, #0f172a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.v2-plan-dots { display: flex; gap: 6px; justify-content: center; margin-top: 12px; } +.v2-plan-dot { + width: 7px; height: 7px; padding: 0; border: 0; border-radius: 50%; cursor: pointer; + background: var(--border, #e4e9f0); transition: background .2s, transform .2s; +} +.v2-plan-dot:hover { transform: scale(1.25); } +.v2-plan-dot.active { background: var(--accent, #2f6df6); } @media (prefers-reduced-motion: reduce) { .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, .v2-plan-orb, @@ -366,7 +404,8 @@ body.ux-v2[data-theme="dark"] .v2-plan-error { .v2-landing[data-screen="welcome"] .v2-screen-welcome, .v2-typing i, .v2-act-spin, .v2-act-chev, .v2-act-tool-body, .v2-act-tool-head, - .v2-choice, .v2-escape-field, .v2-escape-toggle { + .v2-choice, .v2-escape-field, .v2-escape-toggle, + .v2-plan-pager-btn, .v2-plan-dot { animation: none !important; transition-duration: .12s !important; } diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index 2f524c1e..bb4a0923 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -54,7 +54,22 @@ const V2Landing = (() => { } // ── status / error helpers ──────────────────────────────────────── - function showThinking(on) { const t = $('v2-plan-thinking'); if (t) t.classList.toggle('hidden', !on); } + function setThinkingLabel(text) { + const l = document.querySelector('#v2-plan-thinking .v2-plan-thinking-label'); + if (l && text) l.textContent = text; + } + function showThinking(on, label) { + const t = $('v2-plan-thinking'); + if (t) t.classList.toggle('hidden', !on); + if (on && label) setThinkingLabel(label); + } + // Human-readable "what's happening right now" from a tool activity event, + // so the status line names the live operation instead of a static string. + function prettyTool(act) { + const raw = (act && (act.label || act.name)) || 'the next step'; + const s = String(raw).replace(/_/g, ' ').trim(); + return s.charAt(0).toUpperCase() + s.slice(1) + '…'; + } function errorVisible() { const e = $('v2-plan-error'); return !!e && !e.classList.contains('hidden'); } function showPlanError(msg) { const e = $('v2-plan-error'); if (!e) return; @@ -67,17 +82,90 @@ const V2Landing = (() => { function resetSummary() { const list = $('v2-plan-summary'); if (list) list.innerHTML = '

The plan will take shape here as Gently designs it.
'; + planPage = 0; planPages = []; planTitleText = ''; } - // ── activity feed (claude.ai-style collapsible tool cards) ───────── + // ── activity feed: paginated, ONE agent step (turn) per page ─────── + // Instead of one ever-growing scroll, each agent turn — its reasoning + + // the tool calls it made — is a page you flip through with ‹ Prev / Next ›. + // The current question stays pinned below the feed (#v2-plan-ask). A new + // turn auto-advances to its page; you can flip back to review earlier steps. + let feedPages = []; // .v2-act-page elements, one per turn + let feedPage = 0; // index currently shown + let curPageEl = null; // page receiving this turn's content + let pendingNewPage = false; // a turn started; open a fresh page on first content + function feedEl() { return $('v2-plan-activity'); } + function feedPagesWrap() { return feedEl()?.querySelector('.v2-feed-pages'); } function clearActivity() { - const f = feedEl(); if (f) f.innerHTML = ''; + const f = feedEl(); + if (f) { + f.innerHTML = + '' + + '
' + + ''; + } + feedPages = []; feedPage = 0; curPageEl = null; pendingNewPage = false; feedTextEl = null; runningTools = {}; feedHadContent = false; capturedCampaignId = null; hidePlanError(); } + function newFeedPage() { + const wrap = feedPagesWrap(); if (!wrap) return null; + const page = document.createElement('div'); + page.className = 'v2-act-page'; + wrap.appendChild(page); + feedPages.push(page); + curPageEl = page; + feedPage = feedPages.length - 1; // auto-advance to the live step + feedTextEl = null; + drawFeedPager(); + return page; + } + // Where this turn's prose/tool cards land. Opens a fresh page the first time + // content arrives after a turn_start (so content-less command turns don't + // leave empty pages), and lazily on the very first content. + function feedTarget() { + if (pendingNewPage || !curPageEl) { newFeedPage(); pendingNewPage = false; } + return curPageEl; + } + function viewingLatest() { return feedPage >= feedPages.length - 1; } + function drawFeedPager() { + const f = feedEl(); if (!f) return; + const n = feedPages.length; + const i = Math.min(Math.max(feedPage, 0), Math.max(n - 1, 0)); + feedPages.forEach((p, idx) => p.classList.toggle('active', idx === i)); + const bar = f.querySelector('.v2-feed-pager-bar'); + const dots = f.querySelector('.v2-feed-dots'); + if (!bar || !dots) return; + if (n <= 1) { bar.hidden = true; dots.hidden = true; return; } + bar.hidden = false; dots.hidden = false; + bar.innerHTML = ''; + const mkBtn = (txt, disabled, fn) => { + const b = document.createElement('button'); + b.className = 'v2-plan-pager-btn'; b.type = 'button'; b.textContent = txt; + b.disabled = disabled; b.addEventListener('click', fn); + return b; + }; + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; pos.textContent = `Step ${i + 1} of ${n}`; + bar.append( + mkBtn('‹ Prev', i === 0, () => { if (feedPage > 0) { feedPage--; drawFeedPager(); } }), + pos, + mkBtn('Next ›', i === n - 1, () => { if (feedPage < n - 1) { feedPage++; drawFeedPager(); } }), + ); + dots.innerHTML = ''; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Step ${d + 1} of ${n}`); + dot.addEventListener('click', () => { feedPage = d; drawFeedPager(); }); + dots.appendChild(dot); + } + } function scrollFeedIfNearBottom() { + if (!viewingLatest()) return; // don't yank the user off an earlier step const m = document.querySelector('.v2-screen-plan .v2-plan-main'); if (m && (m.scrollHeight - m.scrollTop - m.clientHeight) < 140) m.scrollTop = m.scrollHeight; } @@ -172,10 +260,11 @@ const V2Landing = (() => { const f = feedEl(); if (!f) return; switch (act.kind) { case 'turn_start': - feedTextEl = null; hidePlanError(); clearFallback(); showThinking(true); + feedTextEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); + showThinking(true, 'reviewing your campaign and plan…'); break; case 'thinking': - showThinking(true); + showThinking(true, 'thinking through the next step…'); break; case 'text': { const chunk = act.text || ''; @@ -184,11 +273,11 @@ const V2Landing = (() => { feedTextEl = document.createElement('div'); feedTextEl.className = 'v2-act-text'; feedTextEl._raw = ''; - f.appendChild(feedTextEl); + feedTarget().appendChild(feedTextEl); } feedTextEl._raw += chunk; feedTextEl.innerHTML = renderMd(feedTextEl._raw); - feedHadContent = true; showThinking(true); scrollFeedIfNearBottom(); + feedHadContent = true; showThinking(true, 'composing the response…'); scrollFeedIfNearBottom(); break; } case 'tool_start': { @@ -197,9 +286,9 @@ const V2Landing = (() => { if (act.name === 'ask_user_choice') break; feedTextEl = null; const card = buildToolCard(act, false); - f.appendChild(card); + feedTarget().appendChild(card); (runningTools[act.name] = runningTools[act.name] || []).push(card); - feedHadContent = true; showThinking(true); scrollFeedIfNearBottom(); + feedHadContent = true; showThinking(true, prettyTool(act)); scrollFeedIfNearBottom(); break; } case 'tool_result': { @@ -211,8 +300,8 @@ const V2Landing = (() => { const arr = runningTools[act.name] || []; const card = arr.pop(); if (card) updateToolCard(card, act); - else f.appendChild(buildToolCard(act, true)); - feedHadContent = true; scrollFeedIfNearBottom(); + else feedTarget().appendChild(buildToolCard(act, true)); + feedHadContent = true; setThinkingLabel('working through the next step…'); scrollFeedIfNearBottom(); break; } case 'turn_end': @@ -232,7 +321,7 @@ const V2Landing = (() => { d.className = 'v2-plan-fallback'; d.innerHTML = 'Gently replied in prose — open the conversation to read it.'; d.querySelector('a').addEventListener('click', openChat); - f.appendChild(d); + feedTarget().appendChild(d); } // ── THE PLAN panel: mirror the real campaign tree ────────────────── @@ -254,35 +343,101 @@ const V2Landing = (() => { c = c || {}; return c.shorthand || c.display_name || c.description || 'Plan'; } + // THE PLAN renders as a pager — one phase per page with ‹ Prev / Next ›, + // a position label, and dots — instead of one long scroll. planPage is held + // across re-renders (the panel refetches on every plan-writing tool) so the + // page you're reading doesn't snap back to the start mid-design. + let planPage = 0; + let planPages = []; // [{ name, items }] + let planTitleText = ''; + function renderPlanTree(tree) { - const list = $('v2-plan-summary'); - if (!list || !tree) return; + if (!tree) return; const phases = tree.children || []; const rootItems = tree.items || []; if (!phases.length && !rootItems.length) return; // nothing to show yet — keep placeholder + const pages = []; + if (rootItems.length) pages.push({ name: 'Tasks', items: rootItems }); + phases.forEach(phase => { + if (!phase) return; + pages.push({ name: planName(phase.campaign), items: phase.items || [] }); + }); + planPages = pages; + planTitleText = planName(tree.campaign); + if (planPage >= pages.length) planPage = pages.length - 1; + if (planPage < 0) planPage = 0; + drawPlanPage(); + } + + function drawPlanPage() { + const list = $('v2-plan-summary'); + if (!list) return; + const pages = planPages; + const n = pages.length; + if (!n) return; + const i = Math.min(Math.max(planPage, 0), n - 1); + const page = pages[i]; + list.innerHTML = ''; const title = document.createElement('div'); title.className = 'v2-plan-title-row'; - title.textContent = planName(tree.campaign); + title.textContent = planTitleText; list.appendChild(title); - const addTask = (parent, it) => { - const t = document.createElement('div'); - t.className = 'v2-plan-task'; - t.textContent = it.title || it.shorthand || '(task)'; - parent.appendChild(t); - }; - rootItems.forEach(it => addTask(list, it)); - phases.forEach(phase => { - if (!phase) return; - const wrap = document.createElement('div'); - wrap.className = 'v2-plan-phase'; + + if (n > 1) { + const bar = document.createElement('div'); + bar.className = 'v2-plan-pager'; + const prev = document.createElement('button'); + prev.className = 'v2-plan-pager-btn'; prev.type = 'button'; prev.textContent = '‹ Prev'; + prev.disabled = i === 0; + prev.addEventListener('click', () => { if (planPage > 0) { planPage--; drawPlanPage(); } }); + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; + pos.textContent = `${page.name} · ${i + 1} of ${n}`; + const next = document.createElement('button'); + next.className = 'v2-plan-pager-btn'; next.type = 'button'; next.textContent = 'Next ›'; + next.disabled = i === n - 1; + next.addEventListener('click', () => { if (planPage < n - 1) { planPage++; drawPlanPage(); } }); + bar.append(prev, pos, next); + list.appendChild(bar); + } else { const h = document.createElement('div'); h.className = 'v2-plan-phase-h'; - h.textContent = planName(phase.campaign); - wrap.appendChild(h); - (phase.items || []).forEach(it => addTask(wrap, it)); - list.appendChild(wrap); - }); + h.textContent = page.name; + list.appendChild(h); + } + + const tasks = document.createElement('div'); + tasks.className = 'v2-plan-phase'; + const items = page.items || []; + if (items.length) { + items.forEach(it => { + const t = document.createElement('div'); + t.className = 'v2-plan-task'; + t.textContent = it.title || it.shorthand || '(task)'; + tasks.appendChild(t); + }); + } else { + const e = document.createElement('div'); + e.className = 'v2-plan-task v2-plan-task-empty'; + e.textContent = 'no items in this phase yet'; + tasks.appendChild(e); + } + list.appendChild(tasks); + + if (n > 1) { + const dots = document.createElement('div'); + dots.className = 'v2-plan-dots'; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Go to ${pages[d].name} (${d + 1} of ${n})`); + dot.addEventListener('click', () => { planPage = d; drawPlanPage(); }); + dots.appendChild(dot); + } + list.appendChild(dots); + } } // ── ask rendering (the active question) ──────────────────────────── @@ -470,6 +625,11 @@ const V2Landing = (() => { if (request_id === '*' || (current && request_id === current.reqId)) { current = null; clearAsk(); if (planActive() && !errorVisible()) showThinking(true); + // A question was just answered — the agent's continuation is the + // next step, so open a fresh feed page for it. (A turn stays one + // stream across an ask_user_choice pause, so turn_start alone + // would lump every step of the design into a single page.) + pendingNewPage = true; } }); ClientEventBus.on('AGENT_CONTROL', () => { if (current && planActive()) renderAsk(); }); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 36688f8a..e83eda63 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -83,7 +83,7 @@

Take a quick look

-
thinking through the next step…
+
working through the next step…
@@ -648,6 +649,40 @@

Properties

+ + +
@@ -748,6 +783,7 @@

Properties

+ diff --git a/screenshots/occupancy3d-demo.png b/screenshots/occupancy3d-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..d76885724a4a54650e4e917f0f7359c5ec71be5b GIT binary patch literal 244754 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 TrFVG4Cmh;Z274avatQi=n=0Ej literal 0 HcmV?d00001 From 91ec6d2f5befd489b007c35ddbca8660c8412afe Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 06:59:57 +0530 Subject: [PATCH 025/200] docs: UX v2 interaction-flow / IA audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow/IA audit (not visual) from a live click-audit with the agent on, plus code cross-check. Prioritized findings + fixes: P0 loading-state legibility (stream thinking summary — set thinking display:summarized + handle thinking_delta), first-character truncation in the plan feed, control/auth wall hidden in chat, double ask-mount, ASK_CLEARED never emitted, no path back to welcome, non-stateful routing, resume hard-reload, and the workspace-IA placement of the 3D optical-space view. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ux-v2-flow-audit.md | 106 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/ux-v2-flow-audit.md diff --git a/docs/ux-v2-flow-audit.md b/docs/ux-v2-flow-audit.md new file mode 100644 index 00000000..00bba3e7 --- /dev/null +++ b/docs/ux-v2-flow-audit.md @@ -0,0 +1,106 @@ +# UX v2 — interaction-flow / IA audit + +**Branch:** `feature/ux-v2` (now includes the 3D optical-space view). +**Scope:** the *flow* of the agent-first UI — clicks, how each step renders, how the +workspace is unveiled, moving back/forth between views, resume — **not** the visual look +(the look is fine). Plus where the 3D optical-space view belongs in the new workspace IA. + +**Method:** live click-audit driven through a real browser as a *dev biologist* would use +it, with the agent **live** (Opus 4.8, `--offline` hardware, `GENTLY_NO_AUTH=1` single +controller), cross-checked against the code. Screenshots from the run are in `screenshots/audit-*.png`. + +> Correction to an earlier automated pass: the plan-wizard helpers +> (`buildAskCard`/`answerChoice`/`togglePanel`) are **not** missing — `agent-chat.js` +> exports them and the module loads; the plan wizard works. The real issues are below. + +--- + +## What works (keep it) + +- **The forward path is good.** Entry → one calm choice (Plan / Quick look / "just tell me") + → overlay dismisses to reveal the workspace → grouped rail (NOW / LIBRARY / SYSTEM) drives + everything through one chokepoint (`app.js switchTab`). The welcome→workspace unveil is genuinely nice. +- **The agent-driven plan wizard is strong.** Live, it asked a well-framed scientific question + ("What's the core scientific question this run should capture?") with real C. elegans options, + ran a `query_lab_history` tool with visible provenance, and **assembled THE PLAN panel as each + answer landed** (strain → wavelengths, etc.). The "plan builds as you answer" feel is excellent. +- **The dual-render** (ask shows in the plan stage *and* the chat transcript) is implemented. + +--- + +## Findings (prioritized) + +| # | Pri | Symptom (felt) | Root cause / evidence | Fix | +|---|-----|----------------|------------------------|-----| +| 1 | **P0** | First plan step sat on "working through the next step…" for **~90s** with a static spinner — feels hung. | The wait is the model *thinking*. The streaming call requests **no thinking config** and the stream loop reads only text deltas. `conversation.py:272-275` (only `output_config.effort`), `conversation.py:654-657` (only `event.delta.text`). | Set `thinking={"type":"adaptive","display":"summarized"}` on the stream (`conversation.py:552`); handle `thinking_delta` in the loop (`:654`) and emit as a `thinking` activity; render it live + add an elapsed timer. See §1. | +| 2 | **P1** | Agent's first line renders as **"'d love to help…"** — leading "I" dropped. | Plan-feed streaming path drops the first character of the turn's first text block; the chat transcript renders it correctly (`12_41` vs `12_3` in the run). Plan feed: `landing.js applyActivity` `'text'` case (`:269`). | Most likely the first `AGENT_ACTIVITY`/`text` delta is missed by `landing.js`'s listener (subscribed after the first delta) or coalesced wrong. Confirm with a 1-line repro; the transcript path is the reference. | +| 3 | **P1** | Clicked the primary "Plan an experiment" → plan stage spun forever; the *real* blocker ("Viewing only — control is held by another client / sign in to control") was **hidden in the chat panel**. | Control/auth state isn't surfaced on the landing/plan surface — only in the chat dock. A viewer can enter the plan flow and dead-end. | Surface control/sign-in state on the landing **before** the primary CTA; gate or relabel "Plan an experiment" when `!hasControl`; show the wall on the plan stage, not just chat. | +| 4 | **P1** | (Structural) The same ask renders in **two** stage mounts plus the transcript. | `#v2-plan-ask` **and** `#ask-stage` both render the ask (the overlay covers the workspace copy, so only cosmetic/perf today). Two live regions seen in the run (`12_10` + `12_24`). | One stage mount at a time — suppress `#ask-stage` while the landing overlay owns the ask. | +| 5 | **P1** | Cross-surface clear can desync. | `ASK_CLEARED` is **listened for but emitted nowhere** (`landing.js:624`, `ask-stage.js:43` listen; no emit in repo). Answering works locally because `renderAsk.onPick` clears directly, but stage↔transcript sync relies on the missing signal. | Emit `ASK_CLEARED` the instant a `choice_response` is sent (per the migration plan's Phase-1 blocker), plus on cancel/control-loss/socket-close. | +| 6 | **P1** | **No way back.** Once the landing dismisses, there's no path back to welcome / "start a new plan" from the workspace — must reload. | `dismiss()` is one-way (`landing.js:42-54`); `V2Landing.show()` exists but is never called from the workspace. | Add a "New plan" / "Talk to Gently" entry in the rail or header that re-summons the welcome/plan surface. | +| 7 | **P2** | Browser **Back / refresh don't mean anything**; refresh mid-plan loses state and may re-show the landing. | Entry hash is consumed (`app.js` → `replaceState('/')`, ~`:650-662`); no deliberate URL/state sync; in-memory plan state (`planKickedOff`, feed pages) resets on reload. | Real routing: sync screen/tab to URL/History so Back/forward/refresh resolve; persist or re-hydrate plan progress. | +| 8 | **P2** | **Resume = full page reload** — jarring, re-shows landing, drops chat position. | `session_changed` → `window.location.href='/'` (`websocket.js:147`; `review.js resumeSession ~:101-116`). Flagged in the migration plan. | In-place re-hydration on `session_changed` instead of a hard reload. | +| 9 | **P1 (IA)** | The **3D optical-space view is buried**: SYSTEM → Devices → (Map / Details / **3D**) — a sub-sub-toggle. | It was integrated into the *legacy* Devices tab structure; the ux-v2 grouped rail doesn't surface it. | Promote "the scope in space" to a first-class run-time surface (NOW tier), reconciled with the grouped rail. See §2. | +| 10 | **P2** | Offline / agent-silent dead-ends the wizard at "working…". | `startPlan` campaign fetch falls through silently if offline (`landing.js ~:502-508`); no error path. | Timeout + inline error/retry on the plan stage. | + +--- + +## §1 — Make the loading state legible (P0, the one the user wants first) + +The 90s "working…" is the agent reasoning. The Claude streaming API exposes this on three +channels; gently currently surfaces none of the reasoning: + +- **Thinking** — `content_block_delta` → `thinking_delta`. **Opus 4.8 defaults to + `display:"omitted"` (empty thinking text)**, and gently doesn't set the thinking config at + all on the stream, so there's nothing to show. Unlock: `thinking={"type":"adaptive","display":"summarized"}`. +- **Tool activity** — `input_json_delta` + tool start/stop. **Already flowing** — the plan feed + renders tool cards (saw the `query_lab_history` card with input/result). +- **Text** — `text_delta`. Already flowing (this is the path with the bug #2 truncation). + +**Backend (`gently/harness/conversation.py`):** +1. `:552` `self.claude.messages.stream(...)` — add `thinking={"type":"adaptive","display":"summarized"}` + (keep `output_config.effort`). +2. `:654` event loop — currently only `if hasattr(event.delta, "text")`. Add a branch for + `event.delta.type == "thinking_delta"` → `yield {"type":"thinking","text": event.delta.thinking}`. + +**Frontend (`gently/ui/web/static/js/landing.js`):** `applyActivity` already has a `thinking` +case (`:266`) that only sets a static label — render the streamed thinking text instead, and add +an elapsed timer to `#v2-plan-thinking` so a long think reads as progress, not a hang. + +Net: the reasoning summary + current tool + a timer fill the wait. Only the backend `display` +flag is a new capability; the rest is surfacing data gently already receives. + +--- + +## §2 — Workspace organization & where the 3D view belongs (P1, IA) + +The ux-v2 workspace is organized differently from the old flat tab bar: a **grouped rail** +(NOW: Home/Experiment/Embryos · LIBRARY: Plans/Sessions · SYSTEM: Devices/Calibration/Logs), +a **session-context strip**, and the **AGENT'S VIEW** surface. The 3D optical-space view, +however, lives in the *legacy* Devices structure (`devices.js switchView`, VIEWS = +`['map','details','optical3d']`; `index.html` devices-content Map/Details/3D switcher). + +During an actual run, "where the scope is in space" + the live experiment + the agent's view are +**NOW-tier** concerns, not a System utility three clicks deep. Proposal (to design next): +- Promote the 3D optical-space + live experiment to a first-class run-time surface in the rail + (or make it the default workspace view while a run is active). +- Keep the Devices Map/Details as the System-tier hardware utility; the 3D "scope in space" + graduates out of that toggle. + +--- + +## Recommended sequencing + +1. **P0 loading state** (§1) — highest felt value, mostly surfacing existing data. +2. **P1 quick correctness**: #2 truncation, #3 control-wall surfacing, #4 single ask mount, #5 `ASK_CLEARED` emit. +3. **P1 reachability**: #6 "new plan"/back entry; then #9 the workspace-IA / 3D-placement redesign (its own design pass). +4. **P2 navigation**: #7 real routing, #8 resume re-hydration, #10 offline error path. + +--- + +## Notes / housekeeping + +- Findings 1–5, 10 verified live with the agent on; 6–9 verified from code + the live rail. +- `screenshots/audit-*.png` (live run) and `screenshots/uxv2-*.png` are local evidence (untracked). +- The earlier visual-design exploration (`docs/superpowers/mockups/`, `screenshots/dir-*.png`) is + superseded — the look is staying as-is — and can be deleted. From 9edb766255720b0e50c3d1e63d97d77955b18da3 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 07:20:37 +0530 Subject: [PATCH 026/200] UX v2 P0: live-stream the agent turn + show reasoning during the wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming path collected the entire turn before yielding anything, so the plan wizard sat on a static "working…" spinner for the whole turn (~90s). And the streamed call requested no thinking, so there was no reasoning to show even if it had streamed. - conversation.py: rewrite call_claude_stream to stream live — a worker thread drains the SDK stream and pushes events onto an asyncio queue as they arrive; the coroutine yields text/thinking deltas in real time. Enable adaptive thinking with display="summarized" (+ effort=medium) and emit thinking_delta as {"type":"thinking"} chunks. Full assistant content (incl. thinking blocks) is still replayed from final_message, so the tool loop stays valid. Retry / 400-fallback / refusal-fallback preserved (clean while nothing's been yielded). - agent-chat.js: forward the thinking text on the 'thinking' activity. - landing.js: render streamed reasoning as a dim block in the plan feed and add an elapsed-time counter to the thinking indicator so a long think reads as progress, not a hang. Verified live (Opus 4.8): reasoning + prose stream into the plan feed during the turn with a ticking timer; no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/conversation.py | 190 +++++++++++++++++--------- gently/ui/web/static/js/agent-chat.js | Bin 63820 -> 63846 bytes gently/ui/web/static/js/landing.js | 63 ++++++++- 3 files changed, 178 insertions(+), 75 deletions(-) diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 6ba26262..067e2f85 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -546,80 +546,142 @@ async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_sav """ from anthropic import APIStatusError, BadRequestError - def stream_and_collect(model): - def _run(m): - events = [] - with self.claude.messages.stream( - model=m, - system=system_prompt, - messages=self.conversation_history, - tools=tools, - max_tokens=4096, - ) as stream: - for event in stream: - events.append(event) - return events, stream.get_final_message() - - try: - return _run(model) - except BadRequestError: - # Fable 5 under <30-day org data retention (or unavailable) rejects - # with a 400 — fall back to Opus 4.8 so the turn still streams. - fb = settings.models.refusal_fallback - if not fb or fb == model: - raise - logger.warning( - "Stream model %s rejected the request (400); falling back to %s", model, fb - ) - return _run(fb) + # Live streaming: a worker thread drains the SDK's (blocking) stream and + # pushes each event onto an asyncio queue as it arrives, so this coroutine + # can yield text/thinking deltas in real time instead of collecting the + # whole turn first (which left the UI on a blank spinner for the entire + # turn). thinking=summarized surfaces the model's reasoning during the + # wait. The full assistant content (incl. thinking blocks) is replayed from + # final_message below, so the tool-loop continuation stays valid. + _DONE = object() + + async def _stream_live(model, sink): + """Stream one attempt live: yield delta dicts as they arrive; record + events / final_message / error / full_text into `sink`.""" + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue() + state: dict = {} + + def worker(): + try: + with self.claude.messages.stream( + model=model, + system=system_prompt, + messages=self.conversation_history, + tools=tools, + max_tokens=16000, + # Adaptive thinking with a streamed, human-readable summary — + # this is what fills the "working…" wait. Opus 4.8 defaults to + # display="omitted" (empty thinking text), so it must be set. + thinking={"type": "adaptive", "display": "summarized"}, + output_config={"effort": "medium"}, + ) as stream: + for event in stream: + loop.call_soon_threadsafe(queue.put_nowait, event) + state["final"] = stream.get_final_message() + except BaseException as exc: # noqa: BLE001 — re-raised to caller below + state["error"] = exc + finally: + loop.call_soon_threadsafe(queue.put_nowait, _DONE) + + task = asyncio.create_task(asyncio.to_thread(worker)) + events: list = [] + full_text: list = [] + while True: + item = await queue.get() + if item is _DONE: + break + events.append(item) + if item.type != "content_block_delta": + continue + delta = item.delta + dtype = getattr(delta, "type", None) + if dtype == "thinking_delta": + chunk = getattr(delta, "thinking", "") or "" + if chunk: + yield {"type": "thinking", "text": chunk} + elif dtype == "text_delta" or hasattr(delta, "text"): + chunk = getattr(delta, "text", "") or "" + if chunk: + full_text.append(chunk) + yield {"type": "text", "text": chunk} + await task + sink["events"] = events + sink["full_text"] = full_text + sink["final"] = state.get("final") + sink["error"] = state.get("error") - # Run streaming in thread with retry logic max_retries = 3 retry_delay = 1.0 + fb = settings.models.refusal_fallback + model_in_use = self.model + sink: dict = {} for attempt in range(max_retries): - try: - events, final_message = await asyncio.to_thread(stream_and_collect, self.model) - self._track_token_usage(final_message) + sink = {} + yielded_any = False + async for chunk in _stream_live(model_in_use, sink): + yielded_any = True + yield chunk + err = sink.get("error") + if err is None: break - except APIStatusError as e: - error_type = getattr(e, "body", {}) + # Fable 5 under <30-day data retention (or unavailable) rejects with a + # 400 — fall back to Opus 4.8. Only safe before any partial was streamed. + if isinstance(err, BadRequestError) and fb and fb != model_in_use and not yielded_any: + logger.warning( + "Stream model %s rejected the request (400); falling back to %s", + model_in_use, + fb, + ) + model_in_use = fb + continue + if isinstance(err, APIStatusError): + error_type = getattr(err, "body", {}) if isinstance(error_type, dict): error_type = error_type.get("error", {}).get("type", "") - - if ( + overloaded = ( error_type in ("overloaded_error", "rate_limit_error") - or "overloaded" in str(e).lower() - ): - if attempt < max_retries - 1: - wait_time = retry_delay * (2**attempt) - logger.warning( - f"API overloaded, retrying in {wait_time:.1f}s " - f"(attempt {attempt + 1}/{max_retries})" - ) - yield { - "type": "text", - "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", - } - await asyncio.sleep(wait_time) - continue - raise + or "overloaded" in str(err).lower() + ) + if overloaded and attempt < max_retries - 1 and not yielded_any: + wait_time = retry_delay * (2**attempt) + logger.warning( + "API overloaded, retrying in %.1fs (attempt %d/%d)", + wait_time, + attempt + 1, + max_retries, + ) + yield { + "type": "text", + "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", + } + await asyncio.sleep(wait_time) + continue + raise err else: raise RuntimeError("API overloaded after multiple retries") - # Refusal → retry the whole streamed turn on the fallback model (Opus 4.8) - # before giving up. The original partial output is discarded (we re-collect - # and only yield the fallback's events below). - fb = settings.models.refusal_fallback - if final_message.stop_reason == "refusal" and fb and fb != self.model: - logger.warning("Model %s declined the streamed turn; retrying on %s", self.model, fb) - events, final_message = await asyncio.to_thread(stream_and_collect, fb) + final_message = sink["final"] + full_text = sink["full_text"] + self._track_token_usage(final_message) + + # Refusal → retry on the fallback model. Re-streaming live is only safe when + # the refusal came before any visible output (pre-output refusals carry empty + # content, so nothing was yielded); otherwise we keep the partial we showed. + if final_message.stop_reason == "refusal" and fb and model_in_use != fb and not full_text: + logger.warning("Model %s declined the streamed turn; retrying on %s", model_in_use, fb) + sink = {} + async for chunk in _stream_live(fb, sink): + yield chunk + model_in_use = fb + final_message = sink["final"] + full_text = sink["full_text"] self._track_token_usage(final_message) - # Last resort: if even the fallback declined, surface it and stop — - # discard any partial, don't iterate empty content or process tools. + # Last resort: if even the fallback declined, surface it and stop. if final_message.stop_reason == "refusal": - logger.warning("Claude declined the request (model=%s)", self.model) + logger.warning("Claude declined the request (model=%s)", model_in_use) yield { "type": "text", "text": "(The request was declined by the model's safety system. Try rephrasing.)", @@ -639,7 +701,7 @@ def _run(m): len(final_message.content), tool_block_count, len(tools), - self.model, + model_in_use, ) if tool_block_count > 0 and final_message.stop_reason != "tool_use": logger.error( @@ -648,14 +710,6 @@ def _run(m): final_message.stop_reason, ) - # Process events and yield text - full_text = [] - for event in events: - if event.type == "content_block_delta": - if hasattr(event.delta, "text"): - full_text.append(event.delta.text) - yield {"type": "text", "text": event.delta.text} - # Detect fake XML tool calls in text (Claude writing tool_use as text) joined_text = "".join(full_text) if "" in joined_text or "" in joined_text: diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 0e8514df3e603df1220675ba2cff1c2ce566e7d4..58be6e5c13b417fd95d0f16b42ae953a0a0c3467 100644 GIT binary patch delta 22 ecmX@}iTT+l<_#;uCr^kpnY=HDck_ktnuP$3atpoy delta 22 ecmaF%iTTVY<_#;uCp)bWnH&%-x%oqQ*+Kw_+6z7a diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index bb4a0923..4f0f2917 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -18,6 +18,7 @@ const V2Landing = (() => { let el = null; let current = null; // the ask currently in #v2-plan-ask let feedTextEl = null; // current accumulating prose paragraph in the feed + let feedThinkingEl = null; // current accumulating reasoning (thinking) block let runningTools = {}; // tool name -> stack of running card elements let feedHadContent = false; // did this turn surface anything in the feed? let capturedCampaignId = null; // best-effort id scraped from tool results @@ -58,10 +59,39 @@ const V2Landing = (() => { const l = document.querySelector('#v2-plan-thinking .v2-plan-thinking-label'); if (l && text) l.textContent = text; } + // Elapsed-time counter so a long think reads as progress, not a hang. Starts + // when the thinking indicator first shows and runs until it's hidden (turn end). + let _thinkTimer = null; + let _thinkStart = 0; + function _thinkTick() { + const t = $('v2-plan-thinking'); + if (!t) return; + let el = t.querySelector('.v2-plan-elapsed'); + if (!el) { + el = document.createElement('span'); + el.className = 'v2-plan-elapsed'; + el.style.cssText = 'margin-left:6px;opacity:.55;font-variant-numeric:tabular-nums;'; + t.appendChild(el); + } + const s = Math.round((Date.now() - _thinkStart) / 1000); + el.textContent = s > 0 ? s + 's' : ''; + } function showThinking(on, label) { const t = $('v2-plan-thinking'); if (t) t.classList.toggle('hidden', !on); if (on && label) setThinkingLabel(label); + if (on) { + if (!_thinkTimer) { + _thinkStart = Date.now(); + _thinkTick(); + _thinkTimer = setInterval(_thinkTick, 1000); + } + } else if (_thinkTimer) { + clearInterval(_thinkTimer); + _thinkTimer = null; + const el = t && t.querySelector('.v2-plan-elapsed'); + if (el) el.textContent = ''; + } } // Human-readable "what's happening right now" from a tool activity event, // so the status line names the live operation instead of a static string. @@ -106,7 +136,7 @@ const V2Landing = (() => { '

'; } feedPages = []; feedPage = 0; curPageEl = null; pendingNewPage = false; - feedTextEl = null; runningTools = {}; feedHadContent = false; + feedTextEl = null; feedThinkingEl = null; runningTools = {}; feedHadContent = false; capturedCampaignId = null; hidePlanError(); } @@ -260,15 +290,34 @@ const V2Landing = (() => { const f = feedEl(); if (!f) return; switch (act.kind) { case 'turn_start': - feedTextEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); + feedTextEl = null; feedThinkingEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); showThinking(true, 'reviewing your campaign and plan…'); break; - case 'thinking': - showThinking(true, 'thinking through the next step…'); + case 'thinking': { + // Stream the model's reasoning summary live into the feed as a dim + // block, so the wait shows what the agent is actually considering. + showThinking(true); + const chunk = act.text || ''; + if (!chunk) { setThinkingLabel('thinking through the next step…'); break; } + if (!feedThinkingEl) { + feedThinkingEl = document.createElement('div'); + feedThinkingEl.className = 'v2-act-think'; + feedThinkingEl.style.cssText = + 'font-style:italic;opacity:.7;white-space:pre-wrap;margin:2px 0 8px;font-size:12.5px;line-height:1.5;'; + feedThinkingEl._raw = ''; + feedTarget().appendChild(feedThinkingEl); + } + feedThinkingEl._raw += chunk; + feedThinkingEl.textContent = feedThinkingEl._raw; + feedHadContent = true; + setThinkingLabel('reasoning…'); + scrollFeedIfNearBottom(); break; + } case 'text': { const chunk = act.text || ''; if (!chunk) break; + feedThinkingEl = null; // reasoning block ends when prose begins if (!feedTextEl) { feedTextEl = document.createElement('div'); feedTextEl.className = 'v2-act-text'; @@ -284,7 +333,7 @@ const V2Landing = (() => { // ask_user_choice IS the active question (rendered separately in // #v2-plan-ask) — don't also show it as a feed card. if (act.name === 'ask_user_choice') break; - feedTextEl = null; + feedTextEl = null; feedThinkingEl = null; const card = buildToolCard(act, false); feedTarget().appendChild(card); (runningTools[act.name] = runningTools[act.name] || []).push(card); @@ -296,7 +345,7 @@ const V2Landing = (() => { captureCampaignId(act.full); if (PLAN_TOOLS.has(act.name)) schedulePlanRefresh(); if (act.name === 'ask_user_choice') break; - feedTextEl = null; + feedTextEl = null; feedThinkingEl = null; const arr = runningTools[act.name] || []; const card = arr.pop(); if (card) updateToolCard(card, act); @@ -305,7 +354,7 @@ const V2Landing = (() => { break; } case 'turn_end': - showThinking(false); feedTextEl = null; + showThinking(false); feedTextEl = null; feedThinkingEl = null; refreshPlanPanel(); if (!current && !feedHadContent) showFallback(); break; From f209874a23bd97731ac5c42f222794a6a45fc069 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 07:31:02 +0530 Subject: [PATCH 027/200] Fix create/update_plan_item crash when spec/references passed as JSON strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model often serializes nested tool args (spec, references) as JSON strings instead of objects. create_plan_item stored the raw string, and read-back via _dict_to_plan_item did spec_data.items() on a str → AttributeError, leaving a malformed, unreadable plan item persisted. - planning.py: _coerce_plan_args() parses string spec/references (and int-casts estimated_days) in both create_plan_item and update_plan_item before storing. - file_store.py: _dict_to_plan_item tolerates spec/references persisted as JSON strings (parse on read; fall back cleanly on garbage), so existing bad items load instead of crashing. Verified: string spec/refs hydrate to ImagingSpec/list; malformed spec → None (no crash). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/memory/file_store.py | 13 +++++++++++ gently/harness/plan_mode/tools/planning.py | 27 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 6a8c5eb3..451e7df5 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -2538,6 +2538,14 @@ def _dict_to_plan_item(d: dict) -> PlanItem: imaging_spec = None bench_spec = None + # Tolerate specs persisted as JSON strings (older tool calls that passed + # spec as a string instead of an object) so read-back never crashes. + if isinstance(spec_data, str): + try: + spec_data = json.loads(spec_data) + except (json.JSONDecodeError, TypeError): + spec_data = None + if spec_data: if item_type == PlanItemType.IMAGING: valid = {f.name for f in dataclasses.fields(ImagingSpec)} @@ -2547,6 +2555,11 @@ def _dict_to_plan_item(d: dict) -> PlanItem: bench_spec = BenchSpec(**{k: v for k, v in spec_data.items() if k in valid}) references = d.get("references") or [] + if isinstance(references, str): + try: + references = json.loads(references) or [] + except (json.JSONDecodeError, TypeError): + references = [] return PlanItem( id=d["id"], diff --git a/gently/harness/plan_mode/tools/planning.py b/gently/harness/plan_mode/tools/planning.py index 34785e5f..09e529d3 100644 --- a/gently/harness/plan_mode/tools/planning.py +++ b/gently/harness/plan_mode/tools/planning.py @@ -7,9 +7,34 @@ """ import dataclasses +import json from ...tools.registry import ToolCategory, ToolExample, tool + +def _coerce_plan_args(spec, references, estimated_days): + """The model often serializes nested args (spec/references) as JSON strings + instead of objects — accept either so plan-item creation doesn't store a raw + string (which later breaks ImagingSpec/BenchSpec hydration). Returns the + normalized (spec, references, estimated_days).""" + if isinstance(spec, str): + try: + spec = json.loads(spec) + except (json.JSONDecodeError, TypeError): + spec = None + if isinstance(references, str): + try: + references = json.loads(references) + except (json.JSONDecodeError, TypeError): + references = None + if isinstance(estimated_days, str): + try: + estimated_days = int(estimated_days) + except (ValueError, TypeError): + estimated_days = None + return spec, references, estimated_days + + # --------------------------------------------------------------------------- # Campaign / Phase Management # --------------------------------------------------------------------------- @@ -132,6 +157,7 @@ async def create_plan_item( return "Error: Context store not available" store = agent.context_store + spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days) # Resolve phase_number → subcampaign ID target_campaign_id = campaign_id @@ -226,6 +252,7 @@ async def update_plan_item( from gently.harness.memory.model import PlanItemStatus status_enum = PlanItemStatus(status) if status else None + spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days) store.update_plan_item( item_id=resolved_id, status=status_enum, From 144d8ddda5d1287fb8530324076e59ecda35a50b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 08:56:41 +0530 Subject: [PATCH 028/200] UX v2: add a concision/communication-style section to the plan-mode prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-mode prompt specified what to design in detail but nothing about how to write to the user, so (with Opus 4.8's stronger narration) the agent produced dense questions, paragraph-long ask_user_choice options, and over-explained prose — cognitively heavy for a working biologist. Add an explicit communication-style section: lead with the ask/finding, short questions + short options, plain words over process jargon, one-clause rationale (full reasoning goes to provenance/references), one idea per message. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/plan_mode/prompt.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index 62df77d7..782bd7af 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -79,6 +79,29 @@ IMPORTANT: ALWAYS use ask_user_choice when asking the researcher questions. Never present options as text lists. +## Communication style — keep it light to read + +You're talking to a working biologist, not a software user. Optimize every +user-facing message for fast reading, not completeness: + +- **Lead with the ask or the finding.** The first sentence should be the question, + the decision, or what you found — supporting detail comes after, and only when it + changes what they'd do next. +- **Short questions, short options.** Keep an ask_user_choice question to one line, + and each option to a few-word label plus at most a one-line rationale — never a + paragraph. Trust the biologist to know the domain; don't re-explain standard + concepts (what a histone marker is, why controls matter). +- **Plain words, not process jargon.** Use the field's real terms (strain names, + stages, wavelengths) but drop software/workflow jargon and hedging. +- **Give the short "why", not the survey.** One clause of rationale beats an + exhaustive list of everything you weighed. Put the full reasoning in the spec's + provenance and references, not in the message. +- **One idea per message.** Don't stack caveats, alternatives, and next steps into + one dense block. If something is optional, say so briefly or leave it out. + +Readability and brevity are different — choose readability, but get there by +saying less, not by compressing into fragments or abbreviations. + ## Reading Papers Use read_paper to retrieve and read scientific papers. It accepts: From d061e9d8d1ab65f23e5dae565fb021066ae6451c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 09:14:25 +0530 Subject: [PATCH 029/200] UX v2: run independent read-only tool calls concurrently A turn's tool calls were always awaited one-by-one, so several independent lookups (e.g. search_strains for dlg-1 then ajm-1) ran serially even when the model could issue them together. Add a concurrency fast-path: when EVERY tool in a turn is non-hardware (requires_microscope=False) and non-interactive (not ask_user_choice), fire their tool_start events, asyncio.gather the executions, then emit results. Any microscope action or ask_user_choice in the batch falls back to the existing serial path, so hardware is never raced and interactive prompts/stateful ordering are preserved. Also nudge the plan-mode prompt to batch independent lookups into one turn so the model actually produces parallelizable tool calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/conversation.py | 67 ++++++++++++++++++++++++++++++ gently/harness/plan_mode/prompt.py | 4 ++ 2 files changed, 71 insertions(+) diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 067e2f85..8f6b8b11 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -730,7 +730,74 @@ def worker(): await asyncio.sleep(0.05) tool_results = [] + + # Concurrency fast-path: run a turn's tool calls in parallel when ALL of + # them are non-hardware and non-interactive (e.g. several strain / paper / + # lab-history lookups). Any microscope action or ask_user_choice in the + # batch falls back to the serial path below, so we never race hardware or + # an interactive prompt, and ordering of stateful ops is preserved. + tool_blocks = [b for b in response_content if getattr(b, "type", None) == "tool_use"] + _interactive = {"ask_user_choice"} + + def _parallel_safe(b): + td = self._tool_registry.get(b.name) + return td is not None and not td.requires_microscope and b.name not in _interactive + + handled_parallel = False + if len(tool_blocks) > 1 and all(_parallel_safe(b) for b in tool_blocks): + handled_parallel = True + starts = {b.id: time.time() for b in tool_blocks} + for b in tool_blocks: + yield { + "type": "tool_start", + "tool_name": b.name, + "tool_input": b.input, + "tool_label": tool_label_fn(b.name, b.input), + } + gathered = await asyncio.gather( + *[self._execute_single_tool(b.name, b.input) for b in tool_blocks], + return_exceptions=True, + ) + for b, res in zip(tool_blocks, gathered, strict=True): + if isinstance(res, BaseException): + is_error_flag = True + result_text = f"Error: {res}" + tool_results.append( + { + "type": "tool_result", + "tool_use_id": b.id, + "content": result_text, + "is_error": True, + } + ) + else: + is_error_flag = False + result_text = res if isinstance(res, str) else str(res) + tool_results.append( + {"type": "tool_result", "tool_use_id": b.id, "content": res} + ) + result_summary = next( + (ln.strip() for ln in (result_text or "").splitlines() if ln.strip()), + "", + ) + if len(result_summary) > 140: + result_summary = result_summary[:139] + "…" + result_full = result_text or "" + if len(result_full) > 4000: + result_full = result_full[:4000] + "\n…(truncated)" + yield { + "type": "tool_call", + "tool_name": b.name, + "tool_input": b.input, + "duration": time.time() - starts[b.id], + "result_summary": result_summary, + "result_full": result_full, + "is_error": is_error_flag, + } + for block in response_content: + if handled_parallel: + break if hasattr(block, "type") and block.type == "tool_use": start_time = time.time() diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index 782bd7af..7aefbe3f 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -176,6 +176,10 @@ items, search to confirm strain availability, check the literature for recent protocols, and attach references. Your built-in knowledge is a great starting point for brainstorming — the databases are where you confirm before finalizing. +11. **Batch independent lookups**: When you need several independent reads — multiple + strains, several papers, or a few lab-history queries — request them together in + one turn so they run in parallel. Don't fetch one, wait for it, then fetch the + next; that's slow. (The system runs same-turn read-only lookups concurrently.) """ From a779901bb8f5147c7ddb39a96546bc11e37b3cfd Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 09:29:51 +0530 Subject: [PATCH 030/200] Fix create_plan_item crash when phase_number is a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase_number arrived as "1" (stringified, like spec/references), so get_nth_subcampaign did `1 <= "1"` → TypeError. Coerce phase_number/phase_order to int in the create tool, and make get_nth_subcampaign tolerant of a numeric string. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/memory/file_store.py | 5 +++++ gently/harness/plan_mode/tools/planning.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 451e7df5..c19f785b 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -582,6 +582,11 @@ def get_subcampaigns(self, campaign_id: str) -> list[Campaign]: return children def get_nth_subcampaign(self, parent_id: str, n: int) -> Campaign | None: + # Tolerate n arriving as a numeric string (tool args are often stringified). + try: + n = int(n) + except (ValueError, TypeError): + return None phases = self.get_subcampaigns(parent_id) if 1 <= n <= len(phases): return phases[n - 1] diff --git a/gently/harness/plan_mode/tools/planning.py b/gently/harness/plan_mode/tools/planning.py index 09e529d3..87715acd 100644 --- a/gently/harness/plan_mode/tools/planning.py +++ b/gently/harness/plan_mode/tools/planning.py @@ -158,6 +158,16 @@ async def create_plan_item( store = agent.context_store spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days) + if isinstance(phase_number, str): + try: + phase_number = int(phase_number) + except (ValueError, TypeError): + phase_number = None + if isinstance(phase_order, str): + try: + phase_order = int(phase_order) + except (ValueError, TypeError): + phase_order = -1 # Resolve phase_number → subcampaign ID target_campaign_id = campaign_id From 06741eb8b99ca6a0296058eda9ab573fcf9c4381 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 09:29:51 +0530 Subject: [PATCH 031/200] Restrict tool concurrency to read-only tools; nudge batched plan-item creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency fast-path gated only on requires_microscope=False, which would also have run mutating tools (create_/update_/delete_plan_item) in parallel — they share a campaign's plan file and are order-dependent, so concurrent runs could corrupt it. Tighten the gate to genuinely read-only tools (search_/read_/ query_/get_/list_/recall_/find_/fetch_/lookup_ prefixes). Mutating creates stay serial. The latency win for plan construction is fewer model turns, not parallel writes: add a prompt nudge to emit several create_plan_item calls in one turn (executed serially, safely) instead of one item per turn. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/conversation.py | 22 +++++++++++++++++++++- gently/harness/plan_mode/prompt.py | 4 ++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 8f6b8b11..a3955df0 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -738,10 +738,30 @@ def worker(): # an interactive prompt, and ordering of stateful ops is preserved. tool_blocks = [b for b in response_content if getattr(b, "type", None) == "tool_use"] _interactive = {"ask_user_choice"} + # Only parallelize genuinely read-only tools (independent lookups). Mutating + # tools (create_/update_/delete_/set_…) must stay serial — they share state + # (e.g. a campaign's plan file) and are order-dependent, so concurrent runs + # could race or corrupt it. + _readonly_prefixes = ( + "search_", + "read_", + "query_", + "get_", + "list_", + "recall_", + "find_", + "fetch_", + "lookup_", + ) def _parallel_safe(b): td = self._tool_registry.get(b.name) - return td is not None and not td.requires_microscope and b.name not in _interactive + return ( + td is not None + and not td.requires_microscope + and b.name not in _interactive + and b.name.startswith(_readonly_prefixes) + ) handled_parallel = False if len(tool_blocks) > 1 and all(_parallel_safe(b) for b in tool_blocks): diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index 7aefbe3f..df30bbf9 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -180,6 +180,10 @@ strains, several papers, or a few lab-history queries — request them together in one turn so they run in parallel. Don't fetch one, wait for it, then fetch the next; that's slow. (The system runs same-turn read-only lookups concurrently.) +12. **Build the plan in few turns**: Each turn is a model round-trip, so creating one + item per turn makes plan construction crawl. When writing a phase's items, emit + several create_plan_item calls in a single turn (then set any dependencies in a + follow-up). Fewer turns = a much faster plan. """ From faee1819003eed0ee8e682845ab46880ce74d5da Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 10:12:26 +0530 Subject: [PATCH 032/200] UX v2: plan-done state + restructured THE PLAN panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two flow/IA fixes to the plan wizard, both surfaced in a live click-audit: 1. Plan-ready completion state. When propose_plan lands and the turn settles with no pending question, the wizard now flips to a "ready" state instead of leaving the user parked on the last feed step: the header reads "Your plan is ready — N items across M phases" (counted from the real plan tree), the eyebrow/orb turn green, and the footer link is promoted to a filled primary "Open the workspace ›" button. Resets on the next turn_start so refining drops the banner cleanly. 2. THE PLAN panel readability. Items were a flat gray bullet list with phases labeled by machine shorthand ("nrp-p1"). Each item is now a structured row — phase-relative number (1.1), a type-colored marker (imaging=blue, genetics=purple, analysis=green, bench=amber, decision_point=rose gate-square), the title, and an optional duration. Phases read by their human name ("Phase 1 — Reporter validation"). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/landing.css | 49 ++++++++++++++-- gently/ui/web/static/js/landing.js | 88 ++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css index dad23d0b..49d27d9e 100644 --- a/gently/ui/web/static/css/landing.css +++ b/gently/ui/web/static/css/landing.css @@ -279,6 +279,21 @@ body.ux-v2 .home-hero { display: none; } } .v2-plan-skip:hover { color: var(--text-secondary, #475569); } +/* ── Plan-ready state: the design is done, signpost the finish line ───────── */ +.v2-screen-plan.ready .v2-plan-orb { + background: radial-gradient(closest-side at 38% 34%, #ffffff, #c8f0d4 40%, var(--accent-green, #16a34a) 100%); +} +.v2-screen-plan.ready .v2-plan-who { color: var(--accent-green, #16a34a); } +.v2-screen-plan.ready .v2-plan-foot { border-top-color: color-mix(in srgb, var(--accent-green) 35%, var(--border)); } +/* promote "open the workspace" from a quiet skip link to the primary action */ +.v2-screen-plan.ready #v2-plan-continue { + background: var(--accent-green, #16a34a); color: #fff; + border-radius: 999px; padding: 11px 20px; font-weight: 600; +} +.v2-screen-plan.ready #v2-plan-continue:hover { + color: #fff; background: color-mix(in srgb, var(--accent-green) 88%, #000); +} + /* ── Agent-activity feed: claude.ai-style collapsible tool cards ──────────── */ /* Both screens share the .v2-landing-inner top anchor (no per-screen align flip — that was the welcome→plan lurch). The feed is a fixed-height viewport (height @@ -369,13 +384,37 @@ body.ux-v2[data-theme="dark"] .v2-plan-error { /* plan-panel: phases + tasks (real plan), and a free-text-answer row variant */ .v2-plan-phase { margin-top: 12px; } .v2-plan-phase:first-child { margin-top: 0; } -.v2-plan-phase-h { font-size: 13px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 5px; } -.v2-plan-task { display: flex; gap: 8px; font-size: var(--v2-fs-cap); color: var(--text-secondary, #475569); padding: 2px 0; } -.v2-plan-task::before { content: "·"; color: var(--text-muted, #94a3b8); } +.v2-plan-phase-h { + font-size: var(--v2-fs-eyebrow); font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; color: var(--text-secondary, #475569); margin-bottom: 6px; +} +/* a plan item: "P.I" number · type-color dot · title · optional duration. + the type dot encodes the item kind (imaging/genetics/analysis/…) at a glance. */ +.v2-plan-task { + display: grid; grid-template-columns: auto 8px 1fr auto; align-items: baseline; + gap: 9px; font-size: var(--v2-fs-cap); color: var(--text-secondary, #475569); + padding: 6px 0; border-top: 1px solid color-mix(in srgb, var(--border, #e4e9f0) 55%, transparent); +} +.v2-plan-phase .v2-plan-task:first-child { border-top: 0; } +.v2-task-num { + font: 600 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--text-muted, #94a3b8); font-variant-numeric: tabular-nums; +} +.v2-task-dot { width: 8px; height: 8px; border-radius: 50%; align-self: center; background: var(--text-muted, #94a3b8); } +.v2-task-ttl { min-width: 0; color: var(--text, #0f172a); line-height: 1.45; } +.v2-task-days { + font-size: 10.5px; font-weight: 600; color: var(--text-muted, #94a3b8); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.v2-plan-task.type-imaging .v2-task-dot { background: var(--accent, #2f6df6); } +.v2-plan-task.type-genetics .v2-task-dot { background: #8b5cf6; } +.v2-plan-task.type-analysis .v2-task-dot { background: var(--accent-green, #16a34a); } +.v2-plan-task.type-bench .v2-task-dot { background: #d97706; } +/* decision points read as gates — a rotated square, not a round bead */ +.v2-plan-task.type-decision_point .v2-task-dot { background: #e11d48; border-radius: 1px; transform: rotate(45deg); } .v2-plan-title-row { font-size: var(--v2-fs-sm); font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 8px; } .v2-plan-row.v2-plan-row-freetext .v { font-style: italic; } -.v2-plan-task-empty { color: var(--text-muted, #94a3b8); font-style: italic; } -.v2-plan-task-empty::before { color: transparent; } +.v2-plan-task-empty { grid-column: 1 / -1; color: var(--text-muted, #94a3b8); font-style: italic; } /* THE PLAN pager: ‹ Prev · "Phase · i of N" · Next › + dots, one phase per page */ .v2-plan-pager { display: flex; align-items: center; gap: 8px; margin: 2px 0 12px; } diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index 4f0f2917..623576fc 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -22,6 +22,7 @@ const V2Landing = (() => { let runningTools = {}; // tool name -> stack of running card elements let feedHadContent = false; // did this turn surface anything in the feed? let capturedCampaignId = null; // best-effort id scraped from tool results + let planProposed = false; // propose_plan ran → plan is ready to commit const $ = (id) => document.getElementById(id); @@ -137,7 +138,8 @@ const V2Landing = (() => { } feedPages = []; feedPage = 0; curPageEl = null; pendingNewPage = false; feedTextEl = null; feedThinkingEl = null; runningTools = {}; feedHadContent = false; - capturedCampaignId = null; + capturedCampaignId = null; planProposed = false; + clearPlanReady(); hidePlanError(); } function newFeedPage() { @@ -291,6 +293,7 @@ const V2Landing = (() => { switch (act.kind) { case 'turn_start': feedTextEl = null; feedThinkingEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); + clearPlanReady(); // new work in flight — drop any "ready" state showThinking(true, 'reviewing your campaign and plan…'); break; case 'thinking': { @@ -344,6 +347,7 @@ const V2Landing = (() => { captureCampaignId(act.summary); captureCampaignId(act.full); if (PLAN_TOOLS.has(act.name)) schedulePlanRefresh(); + if (act.name === 'propose_plan' && !act.is_error) planProposed = true; if (act.name === 'ask_user_choice') break; feedTextEl = null; feedThinkingEl = null; const arr = runningTools[act.name] || []; @@ -357,6 +361,10 @@ const V2Landing = (() => { showThinking(false); feedTextEl = null; feedThinkingEl = null; refreshPlanPanel(); if (!current && !feedHadContent) showFallback(); + // Plan proposed and the agent has settled (no pending question) → + // the design is done. Surface a clear "ready" state instead of + // leaving the user parked on the last wizard step. + if (planProposed && !current) showPlanReady(); break; case 'turn_error': showPlanError(act.error || 'Something went wrong — open the conversation for detail.'); @@ -373,6 +381,48 @@ const V2Landing = (() => { feedTarget().appendChild(d); } + // ── plan-ready state ─────────────────────────────────────────────── + // Once the agent has proposed the plan and gone quiet, the wizard is done. + // Mark the screen "ready": rename the header, count phases/items from the + // panel, and promote "open workspace" to the obvious primary action — so the + // finish line is signposted instead of looking like one more wizard step. + function planCounts() { + let phases = 0, items = 0; + planPages.forEach(p => { + if (p.name !== 'Tasks') phases++; + items += (p.items || []).length; + }); + return { phases, items }; + } + function showPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec) return; + sec.classList.add('ready'); + showThinking(false); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · plan ready'; + if (title) { + const { phases, items } = planCounts(); + title.textContent = items + ? `Your plan is ready — ${items} item${items === 1 ? '' : 's'} across ${phases} phase${phases === 1 ? '' : 's'}` + : 'Your plan is ready'; + } + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Open the workspace ›'; + } + function clearPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec || !sec.classList.contains('ready')) return; + sec.classList.remove('ready'); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · planning'; + if (title) title.textContent = "Let's design your run"; + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Continue in workspace ›'; + } + // ── THE PLAN panel: mirror the real campaign tree ────────────────── async function refreshPlanPanel() { try { @@ -392,6 +442,12 @@ const V2Landing = (() => { c = c || {}; return c.shorthand || c.display_name || c.description || 'Plan'; } + // Phases read better by their human name ("Phase 1 — Reporter validation") + // than by their code shorthand ("nrp-p1"), which looks like a machine id. + function phaseName(c) { + c = c || {}; + return c.display_name || c.description || c.shorthand || 'Phase'; + } // THE PLAN renders as a pager — one phase per page with ‹ Prev / Next ›, // a position label, and dots — instead of one long scroll. planPage is held // across re-renders (the panel refetches on every plan-writing tool) so the @@ -409,7 +465,7 @@ const V2Landing = (() => { if (rootItems.length) pages.push({ name: 'Tasks', items: rootItems }); phases.forEach(phase => { if (!phase) return; - pages.push({ name: planName(phase.campaign), items: phase.items || [] }); + pages.push({ name: phaseName(phase.campaign), items: phase.items || [] }); }); planPages = pages; planTitleText = planName(tree.campaign); @@ -442,7 +498,8 @@ const V2Landing = (() => { prev.addEventListener('click', () => { if (planPage > 0) { planPage--; drawPlanPage(); } }); const pos = document.createElement('span'); pos.className = 'v2-plan-pager-pos'; - pos.textContent = `${page.name} · ${i + 1} of ${n}`; + pos.textContent = page.name; // position shown by the dots below + pos.title = `${page.name} · ${i + 1} of ${n}`; const next = document.createElement('button'); next.className = 'v2-plan-pager-btn'; next.type = 'button'; next.textContent = 'Next ›'; next.disabled = i === n - 1; @@ -459,11 +516,30 @@ const V2Landing = (() => { const tasks = document.createElement('div'); tasks.className = 'v2-plan-phase'; const items = page.items || []; + // phase ordinal (1-based) for "P.I" numbering; the rootItems "Tasks" page + // isn't a phase, so it numbers items bare (1, 2, …). + const phaseOrd = pages.slice(0, i + 1).filter(p => p.name !== 'Tasks').length; if (items.length) { - items.forEach(it => { + items.forEach((it, idx) => { + const type = String(it.type || '').toLowerCase(); const t = document.createElement('div'); - t.className = 'v2-plan-task'; - t.textContent = it.title || it.shorthand || '(task)'; + t.className = 'v2-plan-task type-' + (type || 'other'); + const num = document.createElement('span'); + num.className = 'v2-task-num'; + num.textContent = phaseOrd ? `${phaseOrd}.${idx + 1}` : `${idx + 1}`; + const dot = document.createElement('span'); + dot.className = 'v2-task-dot'; + dot.title = type || 'task'; + const ttl = document.createElement('span'); + ttl.className = 'v2-task-ttl'; + ttl.textContent = it.title || it.shorthand || '(task)'; + t.append(num, dot, ttl); + if (it.estimated_days) { + const d = document.createElement('span'); + d.className = 'v2-task-days'; + d.textContent = `${it.estimated_days}d`; + t.append(d); + } tasks.appendChild(t); }); } else { From f6f2ba310c99b1e626d728e386f2d082cf9fa559 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 10:19:08 +0530 Subject: [PATCH 033/200] UX v2: drop wrap-up reasoning litter from the plan feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streamed thinking summary fills the wait, but the trailing "wrap-up" reasoning ("let me wrap this up concisely and offer to export…") was left in the feed as a dim block right next to the answer it precedes — reading as leaked narration. It's a thinking block, not the agent's text output. When the spoken answer begins, remove the immediately-preceding thinking block from the DOM instead of only nulling the pointer. Thinking that precedes a TOOL is kept (it's the rationale for that action); only the reasoning that precedes the answer text is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/js/landing.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index 623576fc..a8cc751c 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -320,7 +320,13 @@ const V2Landing = (() => { case 'text': { const chunk = act.text || ''; if (!chunk) break; - feedThinkingEl = null; // reasoning block ends when prose begins + // The reasoning that immediately precedes the spoken answer is + // wrap-up meta ("let me wrap this up concisely and offer to + // export…") — drop the block entirely so the feed keeps the + // answer, not the narration of getting there. Reasoning that + // precedes a TOOL is left in place (tool_start only nulls the + // pointer) as the rationale for that action. + if (feedThinkingEl) { feedThinkingEl.remove(); feedThinkingEl = null; } if (!feedTextEl) { feedTextEl = document.createElement('div'); feedTextEl.className = 'v2-act-text'; From b8df61074cdd89f5a3675d562540de2a3af9f4e7 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 11:12:46 +0530 Subject: [PATCH 034/200] UX v2: export-plan button replaces the end-of-plan prose upsell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent used to close a finished plan by asking "want me to export this as markdown, or save it as a template?" — a question the plan wizard can't answer (no text input there). Replace it with a real action. - Plan-ready footer gains a quiet "↓ Export plan" button. It fetches the existing /api/campaigns/{id}/export tree and renders a shareable markdown doc client-side (title, goal, phases, numbered items with type, specs, references), then downloads it as {shorthand}.md. Revealed in the ready state, hidden again if a new turn starts. - Plan-mode prompt: after propose_plan, close on the summary and do NOT offer to export / save-as-template / ask "what next?" — the interface owns those now. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/plan_mode/prompt.py | 6 ++ gently/ui/web/static/css/landing.css | 8 +++ gently/ui/web/static/js/landing.js | 87 ++++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 1 + 4 files changed, 102 insertions(+) diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index df30bbf9..055d0df2 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -76,6 +76,12 @@ 3. Set dependencies between items 4. Present the full plan for review with propose_plan +After propose_plan, close with a short confirmation of what the plan contains +(item/phase count, the critical path, anything notable) and stop there. Do NOT +offer to export it, save it as a template, or ask "what would you like to do +next?" — exporting and opening the workspace are handled by the interface, not +this conversation. End on the summary, not an upsell. + IMPORTANT: ALWAYS use ask_user_choice when asking the researcher questions. Never present options as text lists. diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css index 49d27d9e..046675de 100644 --- a/gently/ui/web/static/css/landing.css +++ b/gently/ui/web/static/css/landing.css @@ -278,6 +278,14 @@ body.ux-v2 .home-hero { display: none; } color: var(--text-muted, #94a3b8); padding: 11px 10px; border-radius: 8px; transition: color .2s; } .v2-plan-skip:hover { color: var(--text-secondary, #475569); } +.v2-plan-export { + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm); + font-weight: 600; cursor: pointer; transition: border-color .2s, color .2s, background .2s; +} +.v2-plan-export:hover { border-color: var(--border-strong); color: var(--text, #0f172a); background: var(--bg-hover); } +.v2-plan-export:disabled { opacity: .6; cursor: default; } +.v2-plan-export[hidden] { display: none; } /* ── Plan-ready state: the design is done, signpost the finish line ───────── */ .v2-screen-plan.ready .v2-plan-orb { diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js index a8cc751c..6d058ebe 100644 --- a/gently/ui/web/static/js/landing.js +++ b/gently/ui/web/static/js/landing.js @@ -416,6 +416,8 @@ const V2Landing = (() => { } const cont = $('v2-plan-continue'); if (cont) cont.textContent = 'Open the workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = false; // the plan is final → offer the download } function clearPlanReady() { const sec = document.querySelector('.v2-screen-plan'); @@ -427,6 +429,89 @@ const V2Landing = (() => { if (title) title.textContent = "Let's design your run"; const cont = $('v2-plan-continue'); if (cont) cont.textContent = 'Continue in workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = true; + } + + // ── export the finished plan as a shareable markdown doc ──────────── + // Replaces the agent's end-of-plan "want me to export this?" prose with a + // real action: pull the enriched plan tree (/export) and render it to + // markdown client-side so the biologist can drop it in a doc or share it. + function specToLines(spec) { + let s = spec; + if (typeof s === 'string') { try { s = JSON.parse(s); } catch { return s ? ['- ' + s] : []; } } + if (!s || typeof s !== 'object') return []; + const out = []; + const fmt = (v) => Array.isArray(v) ? v.join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v)); + const pick = (k, label) => { if (s[k] != null && s[k] !== '') out.push(`- **${label}:** ${fmt(s[k])}`); }; + pick('strain', 'Strain'); pick('goal', 'Goal'); + if (Array.isArray(s.channels) && s.channels.length) { + out.push('- **Channels:** ' + s.channels.map(c => `${c.name || '?'} (${c.excitation_nm || '?'} nm${c.exposure_ms ? `, ${c.exposure_ms} ms` : ''})`).join(', ')); + } + pick('num_slices', 'Slices'); pick('interval_s', 'Interval (s)'); pick('temperature_c', 'Temperature (°C)'); + pick('num_embryos', 'Embryos'); pick('start_stage', 'Start stage'); pick('stop_condition', 'Stop condition'); + pick('criteria', 'Criteria'); pick('success_criteria', 'Success criteria'); + return out; + } + function buildPlanMarkdown(tree) { + const L = []; + L.push(`# ${tree.description || tree.shorthand || 'Experimental plan'}`, ''); + if (tree.target) L.push(`**Goal:** ${tree.target}`, ''); + if (tree.shorthand) L.push(`**Plan ID:** \`${tree.shorthand}\``, ''); + L.push(`_Exported from Gently — ${new Date().toLocaleString()}_`, ''); + const renderItems = (items, prefix) => { + (items || []).slice().sort((a, b) => (a.phase_order || 0) - (b.phase_order || 0)).forEach((it, idx) => { + L.push(`### ${prefix}${idx + 1} ${it.title || '(task)'} \`${it.type || 'task'}\``, ''); + if (it.description) L.push(it.description, ''); + const sl = specToLines(it.spec); + if (sl.length) L.push(...sl, ''); + const refs = it.references || []; + if (refs.length) { + L.push('**References:**'); + refs.forEach((r, i) => L.push(`${i + 1}. ${r.citation || r.id || ''}${r.source ? ` _(${r.source})_` : ''}`)); + L.push(''); + } + }); + }; + if ((tree.items || []).length) { L.push('## Tasks', ''); renderItems(tree.items, ''); } + (tree.children || []).forEach((ph, pi) => { + if (!ph) return; + L.push(`## ${ph.display_name || ph.description || ph.shorthand || `Phase ${pi + 1}`}`, ''); + if (ph.target) L.push(ph.target, ''); + renderItems(ph.items, `${pi + 1}.`); + }); + return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; + } + async function resolveCampaignId() { + if (capturedCampaignId) return capturedCampaignId; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); const t = (d.campaigns || [])[0]; return (t && t.campaign && t.campaign.id) || null; } + } catch (e) { /* offline */ } + return null; + } + async function exportPlan() { + const btn = $('v2-plan-export'); + const id = await resolveCampaignId(); + if (!id) { showPlanError('No plan to export yet.'); return; } + if (btn) { btn.disabled = true; btn.textContent = '↓ Exporting…'; } + try { + const r = await fetch(`/api/campaigns/${encodeURIComponent(id)}/export`); + if (!r.ok) throw new Error(`export ${r.status}`); + const tree = await r.json(); + const md = buildPlanMarkdown(tree); + const blob = new Blob([md], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(tree.shorthand || 'plan').replace(/[^\w.-]+/g, '_')}.md`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + showPlanError('Could not export the plan — open the conversation to export it manually.'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '↓ Export plan'; } + } } // ── THE PLAN panel: mirror the real campaign tree ────────────────── @@ -744,6 +829,8 @@ const V2Landing = (() => { if (planChat) planChat.addEventListener('click', openChat); const cont = $('v2-plan-continue'); if (cont) cont.addEventListener('click', dismiss); + const exp = $('v2-plan-export'); + if (exp) exp.addEventListener('click', exportPlan); // The agent's questions + work render in the plan stage while it's active; // once we've receded into the workspace, AskStage (#ask-stage) takes over. diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 3e704938..cd0b1fba 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -96,6 +96,7 @@

Take a quick look

+
From dedc4189be2c091eda2665eb3f28a6836bab4ada Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:01:51 +0530 Subject: [PATCH 035/200] Design doc: active shared lab notebook (memory model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the brainstormed design for gently's memory substrate — the agent+human shared lab notebook (concept trace: gently-project/gently#52): - 3-kind model (Observation/Finding/Question) with author/status/confidence/ scope/links/artifacts as orthogonal fields, not kinds - working-memory vs durable-notebook lifespan split; Question as the hinge between the notebook (knowing) and the plan layer (doing) - two-faced presentation: Notebook tab in LIBRARY + Agent's View reborn as the ambient live edge - retrieval principle: structure→indexes, meaning→models, grounded structured output; embeddings as a rebuildable sidecar - what we keep from the old agent mind vs what we fix (dormant apply_updates loop, siloed types, no provenance/links/consolidation) - build decomposition into 5 increments; first slice = the foundation keystone Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-16-shared-lab-notebook-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md diff --git a/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md new file mode 100644 index 00000000..c715d57d --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md @@ -0,0 +1,162 @@ +# Design: The Active, Shared Lab Notebook (agent + human memory) + +Status: design approved in brainstorm (2026-06-16). Concept trace: gently-project/gently#52. +Branch: `feature/memory-model` (off `feature/ux-v2`, which it depends on for UI). + +## 1. Overview + +Gently should have one **active, shared lab notebook** — a living memory the agent and +the scientist co-author. The agent writes from doing the work; the human adds notes, +literature, thoughts, and annotations; both read it and *think with* it. It accumulates +across timepoints, sessions, and (eventually) systems. + +This replaces today's "agent mind" — a clean conceptual model (`Context`: intentions, +understanding, observations, expectations, attention) that was never engineered into a +working system. We keep its good bones and redesign the structure. + +### What we keep from the old agent mind +- The **file-based, human-browsable YAML store** (`FileContextStore`) — auditable, no DB lock-in. +- **`Observation` as the entry template** (`model.py:379`) — content + refs + significance + embryo/session is already ~90% of a notebook entry. +- **`basis` + derived `confidence`** on knowledge (`Learning`, `model.py:329`). +- **`gently_refs`** for pointing at artifacts (`model.py:394`). +- The **sense → believe → predict → attend** cognitive framing, as the *meaning* of entries. +- The **event bus + `CONTEXT_UPDATED` → browser** path and the existing `/api/context` surface and "Agent's View" panel (`context-surface.js`). + +### What was wrong (and we fix) +- The write loop is **dormant**: `apply_updates()` (`file_store.py:2178`) is never called anywhere. The mind was designed but never wired to fill. +- **Six parallel silos, no links** — `Observation.relates_to` exists but is never populated. A set of disconnected lists, not a connected mind. +- **No author/provenance dimension** — everything is implicitly agent-authored, so it can't be shared/curated. +- **Working and durable memory are mixed** — live per-embryo predictions sit beside things you'd keep forever. +- **No consolidation** — raw never becomes distilled on its own. +- **Type proliferation** — distinctions that should be *fields* (author, status) were encoded as separate *kinds*. + +## 2. The data model + +### Principle +A **kind** exists only if the system stores, reasons about, or *shows* it differently. +Otherwise it is a **field**. This prevents re-proliferation. + +### Three kinds +- **Observation** — a record of something seen, done, read, or noted. *Immutable.* Timestamped evidence. +- **Finding** — a believed claim / conclusion / insight. *Revisable and supersedable*, with a `basis` (the observations it rests on) and a confidence *derived from evidence*. Can be contradicted — a signal, not an error. +- **Question** — an open inquiry being chased. Open→answered lifecycle. The large, long-lived ones **are the organizing spine** (the inquiry thread). + +### Orthogonal fields (shared by all kinds; NOT new kinds) +- `author` — human | agent | perception (a human note, a literature ref, and an agent observation are all Observations with different authors). +- `status` — proposed | confirmed | superseded | answered | … (lifecycle). +- `confidence` — derived from supporting/contradicting evidence; never self-rated by the model. +- `scope` — strain / embryo / session / thread (the cross-cut indexes). +- `links` — typed edges to other entries (supports / contradicts / refines / answers / produced-by). The graph that makes it a wiki, not a log. +- `artifacts` — pointers into `FileStore` (images, volumes, projections, traces, sessions) by reference, never copied. + +### The inquiry thread (the spine) +A `Question` that grew a body: holds the question, a rolling synthesis, status +(open/investigating/resolved), and denormalized scope. Membership lives on the entries +(`threads: [...]`), not in the thread — a flat note pool, derived reverse-indexes +(`by_thread`, `by_strain`, `by_embryo`) that are rebuildable caches. This lets "by question" ++ "by strain" + links coexist over the flat YAML store with no database. + +### Working memory vs. notebook (the lifespan split) +- **Working memory** (transient, the live loop): the agent's predictions and things-to-watch + during a run. A thin runtime layer, *not* notebook entries. When one matters (a violated + prediction), it **graduates** into an Observation. +- **Notebook** (durable): Observations, Findings, Questions — what accumulates. + +### The Question is the hinge between knowing and doing +"What experiments next?" is a Question, but it resolves into **plan items (action)** in the +existing campaign/plan layer, whereas a scientific question resolves into a **Finding +(knowledge)**. Same kind, different exit — the system tells them apart by *what they link to* +(plan items vs findings), no hardcoded subtype. The loop: + +``` +Question ("what next?") → brainstorm over Observations + Findings + → plan items (a campaign) → run them + → Observations → Findings → may answer the scientific Question → thread converges +``` + +Knowledge → action → knowledge. The plan/campaign layer (and its export) is the "action" arm. + +### Mapping the old six types +| Old type | New representation | +|---|---| +| `Observation` | `Observation` (near-identity; `embryo_id/session_id` → `scope`, `gently_refs` → `artifacts`, `relates_to` → `links`) | +| `Learning` | `Finding` (`content`→body, `basis`/`confidence` kept) | +| `Expectation` | working memory; graduates to `Observation` when violated | +| `Watchpoint` | working memory (live monitoring) | +| `Question` | `Question` (passing) or inquiry thread (recurring) | +| `EmbryoUnderstanding` | `Finding`(s) scoped to an embryo; supersession gives stage history | + +## 3. Presentation (two faces) + +The notebook is **active**, so it can't live only in a tab (the write-only-junk-drawer failure). +It has two faces: + +1. **The Notebook tab** (LIBRARY group, beside Plans and Sessions): the reading room. + *Plans = what we'll do · Sessions = what we did · Notebook = what we know.* Landing view + organized by the **inquiry-thread spine**, with strain/embryo **filters** and **search + + link-graph** navigation. A thread page reads like a living entry — the question, its rolling + synthesis, Findings and Observations beneath, and **links out to its campaign(s) in Plans and + runs in Sessions**. The three library tabs interlink rather than duplicate. + +2. **The ambient edge** — the notebook coming to you, in context (Home, mid-session, planning): + the session-start brief, the one-thing surfacing, the brainstorm reply. **This is what + "Agent's View" becomes** — repurposed from near-empty transient cognition into the notebook's + live edge. + +## 4. Retrieval & extraction + +Principle: **structure → indexes (deterministic); meaning & judgment → models.** +(Exact structural retrieval is not a heuristic — it's an index lookup, and a model must not do it.) + +Three-layer pipeline: +1. **Narrow — structural (instant).** Fields/indexes filter by strain, embryo, thread, time, + status, link-traversal. Cuts thousands → dozens. +2. **Recall + rank — semantic.** Embeddings (computed once per entry, kept in a rebuildable + sidecar vector cache; YAML stays source of truth) pull semantically-near candidates; an LLM + **re-ranks/selects** with reasons. Recall cheap, precision is judgment. +3. **Synthesize — generation (model).** Thread summaries, session-start briefs, brainstorm + answers, contradiction calls, consolidated Findings. Non-negotiable: **grounded + cited** + (uncited claim = bug; "not in notebook" is valid), **forced structured output** (validated + objects, never re-scraped prose), **ranking from structural facts not self-rated confidence**. + +Two query faces: +- **Agent**: full pipeline feeding its reasoning. +- **Human search**: *Filter* mode (instant, structural, as-you-type) + *Ask* mode (full grounded + LLM answer with citations). No model in the typeahead path. + +"Unlimited API credits" removes **cost** (lean on models for judgment/synthesis, frequent +consolidation, regenerate thread summaries on change) but not **latency** (keep interactive +filtering model-free) or **hallucination** (grounding + citation + structured output remain the +guardrails). + +## 5. Trust principles +- Append-only evidence; **supersede, never overwrite**, conclusions (the chain is the history). +- Agent and human are **separate, attributed voices**; the agent appends/proposes/links, never + silently rewrites the human's words. +- Every claim **carries its basis** and cites the entries it rests on. +- Confidence is **derived from evidence**, never self-rated. +- **Revisiting is designed in** — stale findings/questions/unrevisited results resurface. + +## 6. Build decomposition (increments) + +The full vision is several independently-shippable increments. Ordered by value-vs-risk: + +1. **Foundation / keystone — make memory accumulate and be browsable.** + Unified `Note` (3 kinds + fields) + store + indexes; wire the dormant producer loop + (`apply_updates`) so a session actually writes Observations/Findings; surface a read-only + **Notebook tab** (thread-organized) + repurpose Agent's View as the live edge. + *Produce (perception/agent) → store (notebook) → consume (tab + ambient read).* +2. **Retrieval & brainstorm** — structural indexes + embeddings sidecar + grounded LLM + synthesis; the "Ask the notebook" + agent brainstorm pipeline. +3. **Human authorship & curation** — human-authored entries/annotations (on images/embryos), + proposed→confirmed curation, supersession UI. +4. **Active surfacing & consolidation** — structural triggers (contradiction / answered-question + / violated-expectation / echo), throttled one-at-a-moment; scheduled reflection passes. +5. **Cross-system** — sharing/sync of the notebook across instances ("shared brain"). + +**First slice = Increment 1** (the keystone): it proves the model end-to-end and turns the +"wired but starving" problem into the first visible win. Increment 1 gets its own +implementation plan. + +## 7. Out of scope (for now) +Cross-system sync (incr. 5), proactive surfacing tuning (incr. 4) — designed here, built later. From e89161fee45f649a98b418bc501f7a5fa6b71e40 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:07:12 +0530 Subject: [PATCH 036/200] =?UTF-8?q?plan:=20notebook=20foundation=20(Note?= =?UTF-8?q?=20model=20+=20NotebookStore)=20=E2=80=94=20increment=201=20key?= =?UTF-8?q?stone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-foundation.md | 645 ++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-foundation.md diff --git a/docs/superpowers/plans/2026-06-16-notebook-foundation.md b/docs/superpowers/plans/2026-06-16-notebook-foundation.md new file mode 100644 index 00000000..4d6a480c --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-foundation.md @@ -0,0 +1,645 @@ +# Notebook Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the unit-testable foundation of the shared lab notebook — the unified `Note` model and a file-backed `NotebookStore` (write / read / scope-query / rebuildable reverse-indexes / link & supersede) — with no UI, API, or agent wiring. + +**Architecture:** A new self-contained module `gently/harness/memory/notebook.py`. One `Note` dataclass (three kinds: observation/finding/question) with author, status, confidence, scope facets (strains/embryos/sessions/threads), typed links, basis, and artifact pointers — orthogonal fields, not subtypes. `NotebookStore` persists one YAML per note under `notebook/notes/{id}_{slug}.yaml` (atomic write, mirroring `FileContextStore`), maintains rebuildable reverse-indexes by strain/embryo/thread, and answers scope+kind+status queries. This is Increment 1's keystone from the design doc (`docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md`). + +**Tech Stack:** Python 3.11, dataclasses, `str`-Enums, PyYAML, pytest (fixtures in `tests/conftest.py`, flat `tests/` layout). + +**Follow-on plans (NOT in scope here):** producer wiring (`apply_updates` → notebook), `/api/notebook` + Notebook tab (UI), retrieval/embeddings + brainstorm. Each ships on top of this foundation. + +--- + +### Task 1: The `Note` model + +**Files:** +- Create: `gently/harness/memory/notebook.py` +- Test: `tests/test_notebook_store.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_store.py +"""Tests for the shared lab notebook: Note model + NotebookStore.""" + +from datetime import datetime + +from gently.harness.memory.model import Confidence +from gently.harness.memory.notebook import ( + Author, + Note, + NoteKind, + NoteStatus, + note_from_dict, + note_to_dict, +) + + +class TestNoteModel: + def test_round_trip_minimal(self): + n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") + d = note_to_dict(n) + assert d["kind"] == "observation" + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default + back = note_from_dict(d) + assert back == n + + def test_round_trip_full(self): + n = Note( + id="def456", + kind=NoteKind.FINDING, + body="temperature shifts timing ~12 min/degC", + author=Author.AGENT, + title="Temp shifts timing", + status=NoteStatus.PROPOSED, + confidence=Confidence.MEDIUM, + strains=["N2", "OH904"], + embryos=["emb_0007"], + sessions=["20260615_1432_x"], + threads=["q_division_temp"], + basis=["obs_1", "obs_2"], + links=[{"rel": "supports", "to": "q_division_temp"}], + artifacts=[{"kind": "projection", "session": "s1", "embryo": "emb_0007", "t": 42}], + created_at=datetime(2026, 6, 16, 11, 0, 0), + updated_at=datetime(2026, 6, 16, 11, 0, 0), + ) + back = note_from_dict(note_to_dict(n)) + assert back == n + assert note_to_dict(n)["confidence"] == "medium" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/harness/memory/notebook.py +""" +The shared lab notebook — unified memory entry (Note) and file-backed store. + +One Note kind taxonomy (observation / finding / question); everything else +(author, status, confidence, scope, links) is an orthogonal field. See +docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + +from .model import Confidence + + +class NoteKind(str, Enum): + OBSERVATION = "observation" # immutable record of what was seen/done/read/noted + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine + + +class Author(str, Enum): + HUMAN = "human" + AGENT = "agent" + PERCEPTION = "perception" + + +class NoteStatus(str, Enum): + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved + SUPERSEDED = "superseded" # replaced by a newer note + + +@dataclass +class Note: + id: str + kind: NoteKind + body: str + author: Author = Author.AGENT + title: str | None = None + status: NoteStatus = NoteStatus.CONFIRMED + confidence: Confidence | None = None + strains: list[str] = field(default_factory=list) + embryos: list[str] = field(default_factory=list) + sessions: list[str] = field(default_factory=list) + threads: list[str] = field(default_factory=list) + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers + superseded_by: str | None = None + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + +def note_to_dict(n: Note) -> dict[str, Any]: + return { + "id": n.id, + "kind": n.kind.value, + "body": n.body, + "author": n.author.value, + "title": n.title, + "status": n.status.value, + "confidence": n.confidence.value if n.confidence else None, + "strains": list(n.strains), + "embryos": list(n.embryos), + "sessions": list(n.sessions), + "threads": list(n.threads), + "basis": list(n.basis), + "links": list(n.links), + "artifacts": list(n.artifacts), + "superseded_by": n.superseded_by, + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + + +def note_from_dict(d: dict[str, Any]) -> Note: + conf = d.get("confidence") + return Note( + id=d["id"], + kind=NoteKind(d["kind"]), + body=d.get("body", ""), + author=Author(d.get("author", "agent")), + title=d.get("title"), + status=NoteStatus(d.get("status", "confirmed")), + confidence=Confidence(conf) if conf else None, + strains=list(d.get("strains") or []), + embryos=list(d.get("embryos") or []), + sessions=list(d.get("sessions") or []), + threads=list(d.get("threads") or []), + basis=list(d.get("basis") or []), + links=list(d.get("links") or []), + artifacts=list(d.get("artifacts") or []), + superseded_by=d.get("superseded_by"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): unified Note model + dict serialization" +``` + +--- + +### Task 2: `NotebookStore` — write & read a note + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (append `NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +from gently.harness.memory.notebook import NotebookStore + + +class TestNotebookStoreReadWrite: + def test_write_assigns_id_and_persists(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.OBSERVATION, body="bean stage at t40") + note_id = store.write_note(n) + assert note_id # non-empty id assigned + files = list((tmp_path / "notebook" / "notes").glob("*.yaml")) + assert len(files) == 1 + assert files[0].name.startswith(note_id + "_") + + def test_get_note_round_trip(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.FINDING, body="x", strains=["N2"], threads=["t1"]) + note_id = store.write_note(n) + got = store.get_note(note_id) + assert got is not None + assert got.id == note_id + assert got.kind == NoteKind.FINDING + assert got.strains == ["N2"] + + def test_get_missing_returns_none(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + assert store.get_note("nope") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: FAIL — `ImportError: cannot import name 'NotebookStore'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# append to gently/harness/memory/notebook.py +import copy +import os +import re +import uuid +from pathlib import Path + +import yaml + + +class NotebookStore: + """File-backed store for notebook Notes. One YAML per note under notes/; + flat pool, rebuildable reverse-indexes (added in Task 3).""" + + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + + # ---- helpers (mirror FileContextStore conventions) ---- + @staticmethod + def _gen_id() -> str: + return str(uuid.uuid4())[:8] + + @staticmethod + def _slugify(text: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return slug[:30] + + def _write_yaml(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False) + os.replace(str(tmp), str(path)) + + def _read_yaml(self, path: Path) -> dict | None: + try: + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + except OSError: + return None + + def _note_path(self, note_id: str) -> Path | None: + return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None) + + # ---- read/write ---- + def write_note(self, note: Note) -> str: + if not note.id: + note.id = self._gen_id() + note.updated_at = datetime.now() + slug = self._slugify(note.title or note.body or note.kind.value) + # remove any stale file for this id (slug may have changed) + old = self._note_path(note.id) + if old is not None: + old.unlink() + self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note)) + return note.id + + def get_note(self, note_id: str) -> Note | None: + path = self._note_path(note_id) + if path is None: + return None + data = self._read_yaml(path) + return note_from_dict(data) if data else None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): NotebookStore write_note/get_note with atomic YAML" +``` + +--- + +### Task 3: Reverse-indexes (by strain / embryo / thread) + rebuild + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookIndex: + def test_index_updated_on_write(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + b = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])) + assert set(store.ids_for_strain("N2")) == {a, b} + assert store.ids_for_strain("OH904") == [b] + assert store.ids_for_strain("missing") == [] + + def test_index_by_embryo_and_thread(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a", + embryos=["e1"], threads=["t1"])) + assert store.ids_for_embryo("e1") == [a] + assert store.ids_for_thread("t1") == [a] + + def test_rebuild_index_from_disk(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + # a fresh store over the same dir must rebuild the index by scanning notes/ + store2 = NotebookStore(tmp_path / "notebook") + assert store2.ids_for_strain("N2") == [a] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'ids_for_strain'` + +- [ ] **Step 3: Write minimal implementation** + +Modify `NotebookStore.__init__` to add index state + rebuild, extend `write_note` to update the index, and add the index methods. Replace the existing `__init__` and `write_note` with these versions and add the new methods: + +```python + # --- replace __init__ --- + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + # reverse indexes: facet -> {value: [note_id, ...]} + self._index: dict[str, dict[str, list[str]]] = { + "strain": {}, "embryo": {}, "thread": {} + } + self.rebuild_index() + + # --- add: facet extraction + index maintenance --- + _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + + def _index_note(self, note: Note) -> None: + for facet, attr in self._FACETS.items(): + for value in getattr(note, attr): + bucket = self._index[facet].setdefault(value, []) + if note.id not in bucket: + bucket.append(note.id) + + def rebuild_index(self) -> None: + """Rebuild reverse-indexes by scanning notes/ (the notes are authoritative; + indexes are disposable caches).""" + self._index = {"strain": {}, "embryo": {}, "thread": {}} + for f in sorted(self.notes_dir.glob("*.yaml")): + data = self._read_yaml(f) + if data: + self._index_note(note_from_dict(data)) + + def ids_for_strain(self, strain: str) -> list[str]: + return list(self._index["strain"].get(strain, [])) + + def ids_for_embryo(self, embryo: str) -> list[str]: + return list(self._index["embryo"].get(embryo, [])) + + def ids_for_thread(self, thread: str) -> list[str]: + return list(self._index["thread"].get(thread, [])) +``` + +Then add an index-update at the end of `write_note` (just before `return note.id`): + +```python + self._index_note(note) + return note.id +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): rebuildable reverse-indexes by strain/embryo/thread" +``` + +--- + +### Task 4: `query_notes` — filter by kind / author / status / scope + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookQuery: + def _seed(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) + store.write_note(Note(id="f1", kind=NoteKind.FINDING, body="f", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + store.write_note(Note(id="q1", kind=NoteKind.QUESTION, body="q", + status=NoteStatus.OPEN, threads=["t1"])) + return store + + def test_query_by_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(kind=NoteKind.FINDING)} + assert ids == {"f1"} + + def test_query_by_thread_scope(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1")} + assert ids == {"f1", "q1"} + + def test_query_by_thread_and_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1", kind=NoteKind.QUESTION)} + assert ids == {"q1"} + + def test_query_by_status(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(status=NoteStatus.OPEN)} + assert ids == {"q1"} + + def test_query_all_sorted_newest_first(self, tmp_path): + store = self._seed(tmp_path) + notes = store.query_notes() + assert len(notes) == 3 + ts = [n.created_at for n in notes] + assert ts == sorted(ts, reverse=True) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'query_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + def query_notes( + self, + *, + kind: NoteKind | None = None, + author: Author | None = None, + status: NoteStatus | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ) -> list[Note]: + """Structural query: narrow by scope via the indexes, then filter by + kind/author/status. Returned newest-first. (No semantic ranking here — + that's a later increment.)""" + # 1. candidate ids — intersect any scope facets given, else all notes + scope_sets: list[set[str]] = [] + if strain is not None: + scope_sets.append(set(self.ids_for_strain(strain))) + if embryo is not None: + scope_sets.append(set(self.ids_for_embryo(embryo))) + if thread is not None: + scope_sets.append(set(self.ids_for_thread(thread))) + if scope_sets: + candidate_ids: set[str] | None = set.intersection(*scope_sets) + else: + candidate_ids = None # means "all" + + # 2. load + filter + results: list[Note] = [] + if candidate_ids is not None: + notes = [n for n in (self.get_note(i) for i in candidate_ids) if n] + else: + notes = [ + note_from_dict(d) + for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml")) + if d + ] + for n in notes: + if kind is not None and n.kind != kind: + continue + if author is not None and n.author != author: + continue + if status is not None and n.status != status: + continue + results.append(n) + results.sort(key=lambda n: n.created_at, reverse=True) + return results +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): query_notes by kind/author/status/scope" +``` + +--- + +### Task 5: `link_notes` and `supersede_note` + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookLinkSupersede: + def test_link_notes_adds_typed_edge(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a")) + b = store.write_note(Note(id="", kind=NoteKind.QUESTION, body="b")) + store.link_notes(a, "supports", b) + got = store.get_note(a) + assert {"rel": "supports", "to": b} in got.links + + def test_supersede_marks_old_and_points_new(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + old = store.write_note(Note(id="", kind=NoteKind.FINDING, body="old claim")) + new = store.write_note(Note(id="", kind=NoteKind.FINDING, body="better claim")) + store.supersede_note(old, new) + old_n = store.get_note(old) + new_n = store.get_note(new) + assert old_n.status == NoteStatus.SUPERSEDED + assert old_n.superseded_by == new + assert {"rel": "refines", "to": old} in new_n.links +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookLinkSupersede -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'link_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + def link_notes(self, from_id: str, rel: str, to_id: str) -> None: + """Add a typed edge from one note to another (append-only).""" + note = self.get_note(from_id) + if note is None: + raise KeyError(from_id) + edge = {"rel": rel, "to": to_id} + if edge not in note.links: + note.links.append(edge) + self.write_note(note) + + def supersede_note(self, old_id: str, new_id: str) -> None: + """Mark old as superseded (kept, never deleted) and link the new note + back to it as a refinement — the chain is the intellectual history.""" + old = self.get_note(old_id) + if old is None: + raise KeyError(old_id) + old.status = NoteStatus.SUPERSEDED + old.superseded_by = new_id + self.write_note(old) + self.link_notes(new_id, "refines", old_id) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py -v` +Expected: PASS (all tests across all classes pass) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): link_notes + supersede_note (append-only history)" +``` + +--- + +## Self-Review + +**Spec coverage (against the design doc §2 data model):** +- Three kinds (Observation/Finding/Question) → Task 1 `NoteKind`. ✓ +- Orthogonal fields (author/status/confidence/scope/links/artifacts) → Task 1 `Note`. ✓ +- Flat note pool + rebuildable reverse-indexes (strain/embryo/thread) → Tasks 2-3. ✓ +- "By question + by strain + links coexist over flat YAML, no DB" → Tasks 3-4 (indexes + scope-intersect query). ✓ +- Append-only / supersede-never-overwrite → Task 5 `supersede_note`. ✓ +- Typed links graph → Tasks 1 (`links`) + 5 (`link_notes`). ✓ +- *Deferred to follow-on plans (correctly out of scope):* inquiry-thread object, working-memory split, producer wiring, API/tab, embeddings/retrieval, consolidation. Noted in header. + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `Note`, `NoteKind`, `Author`, `NoteStatus`, `note_to_dict`/`note_from_dict`, and `NotebookStore.{write_note,get_note,rebuild_index,ids_for_strain,ids_for_embryo,ids_for_thread,query_notes,link_notes,supersede_note}` are named identically across all tasks and tests. `Confidence` is imported from `.model` (confirmed to exist). ✓ From 60cd40a49cb9abf2c4528e669df74271fa099f15 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:12:56 +0530 Subject: [PATCH 037/200] feat(notebook): unified Note model + dict serialization --- gently/harness/memory/notebook.py | 109 ++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 47 +++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 gently/harness/memory/notebook.py create mode 100644 tests/test_notebook_store.py diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py new file mode 100644 index 00000000..49e35b1c --- /dev/null +++ b/gently/harness/memory/notebook.py @@ -0,0 +1,109 @@ +""" +The shared lab notebook — unified memory entry (Note) and file-backed store. + +One Note kind taxonomy (observation / finding / question); everything else +(author, status, confidence, scope, links) is an orthogonal field. See +docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md. +""" + +from __future__ import annotations + +import copy +import os +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml + +from .model import Confidence + + +class NoteKind(str, Enum): + OBSERVATION = "observation" # immutable record of what was seen/done/read/noted + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine + + +class Author(str, Enum): + HUMAN = "human" + AGENT = "agent" + PERCEPTION = "perception" + + +class NoteStatus(str, Enum): + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved + SUPERSEDED = "superseded" # replaced by a newer note + + +@dataclass +class Note: + id: str + kind: NoteKind + body: str + author: Author = Author.AGENT + title: str | None = None + status: NoteStatus = NoteStatus.CONFIRMED + confidence: Confidence | None = None + strains: list[str] = field(default_factory=list) + embryos: list[str] = field(default_factory=list) + sessions: list[str] = field(default_factory=list) + threads: list[str] = field(default_factory=list) + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers + superseded_by: str | None = None + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + +def note_to_dict(n: Note) -> dict[str, Any]: + return { + "id": n.id, + "kind": n.kind.value, + "body": n.body, + "author": n.author.value, + "title": n.title, + "status": n.status.value, + "confidence": n.confidence.value if n.confidence else None, + "strains": list(n.strains), + "embryos": list(n.embryos), + "sessions": list(n.sessions), + "threads": list(n.threads), + "basis": list(n.basis), + "links": list(n.links), + "artifacts": list(n.artifacts), + "superseded_by": n.superseded_by, + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + + +def note_from_dict(d: dict[str, Any]) -> Note: + conf = d.get("confidence") + return Note( + id=d["id"], + kind=NoteKind(d["kind"]), + body=d.get("body", ""), + author=Author(d.get("author", "agent")), + title=d.get("title"), + status=NoteStatus(d.get("status", "confirmed")), + confidence=Confidence(conf) if conf else None, + strains=list(d.get("strains") or []), + embryos=list(d.get("embryos") or []), + sessions=list(d.get("sessions") or []), + threads=list(d.get("threads") or []), + basis=list(d.get("basis") or []), + links=list(d.get("links") or []), + artifacts=list(d.get("artifacts") or []), + superseded_by=d.get("superseded_by"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py new file mode 100644 index 00000000..fa04a667 --- /dev/null +++ b/tests/test_notebook_store.py @@ -0,0 +1,47 @@ +"""Tests for the shared lab notebook: Note model + NotebookStore.""" + +from datetime import datetime + +from gently.harness.memory.model import Confidence +from gently.harness.memory.notebook import ( + Author, + Note, + NoteKind, + NoteStatus, + note_from_dict, + note_to_dict, +) + + +class TestNoteModel: + def test_round_trip_minimal(self): + n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") + d = note_to_dict(n) + assert d["kind"] == "observation" + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default + back = note_from_dict(d) + assert back == n + + def test_round_trip_full(self): + n = Note( + id="def456", + kind=NoteKind.FINDING, + body="temperature shifts timing ~12 min/degC", + author=Author.AGENT, + title="Temp shifts timing", + status=NoteStatus.PROPOSED, + confidence=Confidence.MEDIUM, + strains=["N2", "OH904"], + embryos=["emb_0007"], + sessions=["20260615_1432_x"], + threads=["q_division_temp"], + basis=["obs_1", "obs_2"], + links=[{"rel": "supports", "to": "q_division_temp"}], + artifacts=[{"kind": "projection", "session": "s1", "embryo": "emb_0007", "t": 42}], + created_at=datetime(2026, 6, 16, 11, 0, 0), + updated_at=datetime(2026, 6, 16, 11, 0, 0), + ) + back = note_from_dict(note_to_dict(n)) + assert back == n + assert note_to_dict(n)["confidence"] == "medium" From d15607dc62f81e76c46874c3f1661bb68fa4b20d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:15:26 +0530 Subject: [PATCH 038/200] feat(notebook): NotebookStore write_note/get_note with atomic YAML --- gently/harness/memory/notebook.py | 60 ++++++++++++++++++++++++++++++- tests/test_notebook_store.py | 26 ++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 49e35b1c..f3ec01e5 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -8,7 +8,6 @@ from __future__ import annotations -import copy import os import re import uuid @@ -107,3 +106,62 @@ def note_from_dict(d: dict[str, Any]) -> Note: created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) + + +class NotebookStore: + """File-backed store for notebook Notes. One YAML per note under notes/; + flat pool, rebuildable reverse-indexes (added in Task 3).""" + + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + + # ---- helpers (mirror FileContextStore conventions) ---- + @staticmethod + def _gen_id() -> str: + return str(uuid.uuid4())[:8] + + @staticmethod + def _slugify(text: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return slug[:30] + + def _write_yaml(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False) + os.replace(str(tmp), str(path)) + + def _read_yaml(self, path: Path) -> dict | None: + try: + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + except OSError: + return None + + def _note_path(self, note_id: str) -> Path | None: + return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None) + + # ---- read/write ---- + def write_note(self, note: Note) -> str: + if not note.id: + note.id = self._gen_id() + note.updated_at = datetime.now() + slug = self._slugify(note.title or note.body or note.kind.value) + # remove any stale file for this id (slug may have changed) + old = self._note_path(note.id) + if old is not None: + old.unlink() + self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note)) + return note.id + + def get_note(self, note_id: str) -> Note | None: + path = self._note_path(note_id) + if path is None: + return None + data = self._read_yaml(path) + return note_from_dict(data) if data else None diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index fa04a667..c157ea81 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -8,6 +8,7 @@ Note, NoteKind, NoteStatus, + NotebookStore, note_from_dict, note_to_dict, ) @@ -45,3 +46,28 @@ def test_round_trip_full(self): back = note_from_dict(note_to_dict(n)) assert back == n assert note_to_dict(n)["confidence"] == "medium" + + +class TestNotebookStoreReadWrite: + def test_write_assigns_id_and_persists(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.OBSERVATION, body="bean stage at t40") + note_id = store.write_note(n) + assert note_id # non-empty id assigned + files = list((tmp_path / "notebook" / "notes").glob("*.yaml")) + assert len(files) == 1 + assert files[0].name.startswith(note_id + "_") + + def test_get_note_round_trip(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.FINDING, body="x", strains=["N2"], threads=["t1"]) + note_id = store.write_note(n) + got = store.get_note(note_id) + assert got is not None + assert got.id == note_id + assert got.kind == NoteKind.FINDING + assert got.strains == ["N2"] + + def test_get_missing_returns_none(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + assert store.get_note("nope") is None From 5225811727179ba401690c92450fac8cf5a55262 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:17:39 +0530 Subject: [PATCH 039/200] feat(notebook): rebuildable reverse-indexes by strain/embryo/thread --- gently/harness/memory/notebook.py | 33 +++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 24 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index f3ec01e5..3f776e68 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -112,12 +112,44 @@ class NotebookStore: """File-backed store for notebook Notes. One YAML per note under notes/; flat pool, rebuildable reverse-indexes (added in Task 3).""" + _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + def __init__(self, notebook_dir: Path): self.root = Path(notebook_dir) self.notes_dir = self.root / "notes" self.index_dir = self.root / "index" self.notes_dir.mkdir(parents=True, exist_ok=True) self.index_dir.mkdir(parents=True, exist_ok=True) + # reverse indexes: facet -> {value: [note_id, ...]} + self._index: dict[str, dict[str, list[str]]] = { + "strain": {}, "embryo": {}, "thread": {} + } + self.rebuild_index() + + # ---- reverse indexes (notes are authoritative; indexes are caches) ---- + def _index_note(self, note: Note) -> None: + for facet, attr in self._FACETS.items(): + for value in getattr(note, attr): + bucket = self._index[facet].setdefault(value, []) + if note.id not in bucket: + bucket.append(note.id) + + def rebuild_index(self) -> None: + """Rebuild reverse-indexes by scanning notes/.""" + self._index = {"strain": {}, "embryo": {}, "thread": {}} + for f in sorted(self.notes_dir.glob("*.yaml")): + data = self._read_yaml(f) + if data: + self._index_note(note_from_dict(data)) + + def ids_for_strain(self, strain: str) -> list[str]: + return list(self._index["strain"].get(strain, [])) + + def ids_for_embryo(self, embryo: str) -> list[str]: + return list(self._index["embryo"].get(embryo, [])) + + def ids_for_thread(self, thread: str) -> list[str]: + return list(self._index["thread"].get(thread, [])) # ---- helpers (mirror FileContextStore conventions) ---- @staticmethod @@ -157,6 +189,7 @@ def write_note(self, note: Note) -> str: if old is not None: old.unlink() self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note)) + self._index_note(note) return note.id def get_note(self, note_id: str) -> Note | None: diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index c157ea81..75b85381 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -71,3 +71,27 @@ def test_get_note_round_trip(self, tmp_path): def test_get_missing_returns_none(self, tmp_path): store = NotebookStore(tmp_path / "notebook") assert store.get_note("nope") is None + + +class TestNotebookIndex: + def test_index_updated_on_write(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + b = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])) + assert set(store.ids_for_strain("N2")) == {a, b} + assert store.ids_for_strain("OH904") == [b] + assert store.ids_for_strain("missing") == [] + + def test_index_by_embryo_and_thread(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a", + embryos=["e1"], threads=["t1"])) + assert store.ids_for_embryo("e1") == [a] + assert store.ids_for_thread("t1") == [a] + + def test_rebuild_index_from_disk(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + # a fresh store over the same dir must rebuild the index by scanning notes/ + store2 = NotebookStore(tmp_path / "notebook") + assert store2.ids_for_strain("N2") == [a] From 9ba23c9d3e34f3bfae0e85cf8827f6ea93c1a9bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:19:30 +0530 Subject: [PATCH 040/200] feat(notebook): query_notes by kind/author/status/scope --- gently/harness/memory/notebook.py | 46 +++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 38 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 3f776e68..0678d9f4 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -198,3 +198,49 @@ def get_note(self, note_id: str) -> Note | None: return None data = self._read_yaml(path) return note_from_dict(data) if data else None + + def query_notes( + self, + *, + kind: NoteKind | None = None, + author: Author | None = None, + status: NoteStatus | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ) -> list[Note]: + """Structural query: narrow by scope via the indexes, then filter by + kind/author/status. Returned newest-first. (No semantic ranking here — + that's a later increment.)""" + # 1. candidate ids — intersect any scope facets given, else all notes + scope_sets: list[set[str]] = [] + if strain is not None: + scope_sets.append(set(self.ids_for_strain(strain))) + if embryo is not None: + scope_sets.append(set(self.ids_for_embryo(embryo))) + if thread is not None: + scope_sets.append(set(self.ids_for_thread(thread))) + candidate_ids: set[str] | None = ( + set.intersection(*scope_sets) if scope_sets else None + ) + + # 2. load + filter + if candidate_ids is not None: + notes = [n for n in (self.get_note(i) for i in candidate_ids) if n] + else: + notes = [ + note_from_dict(d) + for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml")) + if d + ] + results: list[Note] = [] + for n in notes: + if kind is not None and n.kind != kind: + continue + if author is not None and n.author != author: + continue + if status is not None and n.status != status: + continue + results.append(n) + results.sort(key=lambda n: n.created_at, reverse=True) + return results diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 75b85381..492aa7fd 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -95,3 +95,41 @@ def test_rebuild_index_from_disk(self, tmp_path): # a fresh store over the same dir must rebuild the index by scanning notes/ store2 = NotebookStore(tmp_path / "notebook") assert store2.ids_for_strain("N2") == [a] + + +class TestNotebookQuery: + def _seed(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) + store.write_note(Note(id="f1", kind=NoteKind.FINDING, body="f", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + store.write_note(Note(id="q1", kind=NoteKind.QUESTION, body="q", + status=NoteStatus.OPEN, threads=["t1"])) + return store + + def test_query_by_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(kind=NoteKind.FINDING)} + assert ids == {"f1"} + + def test_query_by_thread_scope(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1")} + assert ids == {"f1", "q1"} + + def test_query_by_thread_and_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1", kind=NoteKind.QUESTION)} + assert ids == {"q1"} + + def test_query_by_status(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(status=NoteStatus.OPEN)} + assert ids == {"q1"} + + def test_query_all_sorted_newest_first(self, tmp_path): + store = self._seed(tmp_path) + notes = store.query_notes() + assert len(notes) == 3 + ts = [n.created_at for n in notes] + assert ts == sorted(ts, reverse=True) From 458810ad36e0d99f6615b1174cd601d8c9497935 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:22:09 +0530 Subject: [PATCH 041/200] feat(notebook): link_notes + supersede_note (append-only history) --- gently/harness/memory/notebook.py | 22 ++++++++++++++++++++++ tests/test_notebook_store.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 0678d9f4..f6e23b4d 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -244,3 +244,25 @@ def query_notes( results.append(n) results.sort(key=lambda n: n.created_at, reverse=True) return results + + # ---- links + supersession (append-only history) ---- + def link_notes(self, from_id: str, rel: str, to_id: str) -> None: + """Add a typed edge from one note to another (append-only).""" + note = self.get_note(from_id) + if note is None: + raise KeyError(from_id) + edge = {"rel": rel, "to": to_id} + if edge not in note.links: + note.links.append(edge) + self.write_note(note) + + def supersede_note(self, old_id: str, new_id: str) -> None: + """Mark old as superseded (kept, never deleted) and link the new note + back to it as a refinement — the chain is the intellectual history.""" + old = self.get_note(old_id) + if old is None: + raise KeyError(old_id) + old.status = NoteStatus.SUPERSEDED + old.superseded_by = new_id + self.write_note(old) + self.link_notes(new_id, "refines", old_id) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 492aa7fd..3a7b5294 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -133,3 +133,24 @@ def test_query_all_sorted_newest_first(self, tmp_path): assert len(notes) == 3 ts = [n.created_at for n in notes] assert ts == sorted(ts, reverse=True) + + +class TestNotebookLinkSupersede: + def test_link_notes_adds_typed_edge(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a")) + b = store.write_note(Note(id="", kind=NoteKind.QUESTION, body="b")) + store.link_notes(a, "supports", b) + got = store.get_note(a) + assert {"rel": "supports", "to": b} in got.links + + def test_supersede_marks_old_and_points_new(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + old = store.write_note(Note(id="", kind=NoteKind.FINDING, body="old claim")) + new = store.write_note(Note(id="", kind=NoteKind.FINDING, body="better claim")) + store.supersede_note(old, new) + old_n = store.get_note(old) + new_n = store.get_note(new) + assert old_n.status == NoteStatus.SUPERSEDED + assert old_n.superseded_by == new + assert {"rel": "refines", "to": old} in new_n.links From fed68218b868c8622dd14846295ae525cb212ab9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:33:35 +0530 Subject: [PATCH 042/200] plan: notebook producer bridge (apply_updates -> notebook) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-16-notebook-producer-bridge.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md diff --git a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md new file mode 100644 index 00000000..d518f621 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md @@ -0,0 +1,251 @@ +# Notebook Producer Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the existing agent-memory write path actually populate the shared notebook — when `FileContextStore.apply_updates()` records observations and learnings, mirror them into the `NotebookStore` as Notes. + +**Architecture:** Pure converters (`observation_to_note`, `learning_to_note`) in `notebook.py`; a lazy `FileContextStore.notebook` property rooted at `agent_dir/notebook`; a guarded mirror step at the end of `apply_updates`. Builds on the foundation plan (`2026-06-16-notebook-foundation.md`). Backend-only, no UI/agent-loop changes. Transitional dual-write (legacy YAML + notebook) — legacy silos retire in a later increment. + +**Tech Stack:** Python 3.11, dataclasses, PyYAML, pytest (`file_context_store` fixture in `tests/conftest.py`). + +**Out of scope:** wiring the live loop to *call* `apply_updates` (separate increment); read API + Notebook tab; mapping expectations/watchpoints (they're working memory, not notebook entries — see design doc §2). + +--- + +### Task 1: Converters — Observation/Learning → Note + +**Files:** +- Modify: `gently/harness/memory/notebook.py` +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +from datetime import datetime as _dt + +from gently.harness.memory.model import Learning, Observation +from gently.harness.memory.notebook import learning_to_note, observation_to_note + + +class TestConverters: + def test_observation_to_note(self): + obs = Observation( + id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), type="milestone", + content="nerve ring formed", embryo_id="e1", session_id="s1", + relates_to=["o0"], gently_refs={"kind": "projection", "t": 42}, + ) + n = observation_to_note(obs) + assert n.id == "o1" + assert n.kind == NoteKind.OBSERVATION + assert n.body == "nerve ring formed" + assert n.author == Author.AGENT + assert n.embryos == ["e1"] + assert n.sessions == ["s1"] + assert {"rel": "relates_to", "to": "o0"} in n.links + assert n.artifacts == [{"kind": "projection", "t": 42}] + assert n.created_at == _dt(2026, 6, 16, 9, 0, 0) + + def test_learning_to_note(self): + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + n = learning_to_note(lrn) + assert n.id == "l1" + assert n.kind == NoteKind.FINDING + assert n.body == "rings form by comma" + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.confidence == Confidence.HIGH +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v` +Expected: FAIL — `ImportError: cannot import name 'observation_to_note'` + +- [ ] **Step 3: Write minimal implementation** + +Add the model imports and converters to `notebook.py`. Extend the existing model import line: + +```python +from .model import Confidence, Learning, Observation +``` + +Append at end of `notebook.py` (module-level functions, after `note_from_dict`): + +```python +def observation_to_note(obs: Observation) -> Note: + """Bridge a legacy Observation into a notebook Note (kind=observation).""" + return Note( + id=obs.id, + kind=NoteKind.OBSERVATION, + body=obs.content, + author=Author.AGENT, + embryos=[obs.embryo_id] if obs.embryo_id else [], + sessions=[obs.session_id] if obs.session_id else [], + links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])], + artifacts=[obs.gently_refs] if obs.gently_refs else [], + created_at=obs.timestamp, + updated_at=obs.timestamp, + ) + + +def learning_to_note(learning: Learning) -> Note: + """Bridge a legacy Learning into a notebook Note (kind=finding, proposed).""" + return Note( + id=learning.id, + kind=NoteKind.FINDING, + body=learning.content, + author=Author.AGENT, + status=NoteStatus.PROPOSED, + confidence=learning.confidence, + created_at=learning.created_at, + updated_at=learning.created_at, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): Observation/Learning -> Note converters" +``` + +--- + +### Task 2: `FileContextStore.notebook` property + +**Files:** +- Modify: `gently/harness/memory/file_store.py` (add property near `apply_updates`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestContextStoreNotebook: + def test_notebook_property_rooted_under_agent_dir(self, file_context_store): + nb = file_context_store.notebook + assert nb.root == file_context_store.agent_dir / "notebook" + + def test_notebook_property_is_cached(self, file_context_store): + assert file_context_store.notebook is file_context_store.notebook +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v` +Expected: FAIL — `AttributeError: 'FileContextStore' object has no attribute 'notebook'` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/harness/memory/file_store.py`, add this property immediately **before** `def apply_updates(self, updates: ContextUpdates):` (line ~2178): + +```python + @property + def notebook(self): + """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" + nb = getattr(self, "_notebook", None) + if nb is None: + from .notebook import NotebookStore + nb = NotebookStore(self.agent_dir / "notebook") + self._notebook = nb + return nb + +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/file_store.py tests/test_notebook_store.py +git commit -m "feat(notebook): FileContextStore.notebook lazy property" +``` + +--- + +### Task 3: Mirror observations & learnings in `apply_updates` + +**Files:** +- Modify: `gently/harness/memory/file_store.py` (`apply_updates`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestApplyUpdatesMirror: + def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + obs = Observation(id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), + type="milestone", content="ring formed", embryo_id="e1") + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) + + bodies = {n.body for n in cs.notebook.query_notes()} + assert "ring formed" in bodies + assert "rings form by comma" in bodies + assert cs.notebook.ids_for_embryo("e1") == ["o1"] + + def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + cs.apply_updates(ContextUpdates()) + assert cs.notebook.query_notes() == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v` +Expected: FAIL — `assert "ring formed" in set()` (notebook not populated yet) + +- [ ] **Step 3: Write minimal implementation** + +In `gently/harness/memory/file_store.py`, at the END of `apply_updates` (after the `if updates.new_focus is not None:` block), append: + +```python + # Mirror new observations & learnings into the shared notebook + # (best-effort — a notebook failure never breaks the legacy write). + from .notebook import learning_to_note, observation_to_note + + try: + for obs in updates.new_observations: + self.notebook.write_note(observation_to_note(obs)) + for learning in updates.new_learnings: + self.notebook.write_note(learning_to_note(learning)) + except Exception: + logger.warning("notebook mirror failed", exc_info=True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Run full notebook suite + commit** + +Run: `python -m pytest tests/test_notebook_store.py -q` +Expected: all pass + +```bash +git add gently/harness/memory/file_store.py tests/test_notebook_store.py +git commit -m "feat(notebook): apply_updates mirrors observations & learnings into notebook" +``` + +--- + +## Self-Review + +**Spec coverage:** Producer wiring (design doc increment 1b) — `apply_updates` now populates the notebook. ✓ Converters honor the model (Observation→observation note, Learning→finding/proposed). ✓ Working-memory types (expectation/watchpoint) intentionally not mirrored (design §2). ✓ +**Placeholder scan:** none; complete code + commands throughout. ✓ +**Type consistency:** `observation_to_note`/`learning_to_note`, `FileContextStore.notebook`, `NoteKind`/`Author`/`NoteStatus`/`Confidence` match the foundation module and `model.py` (`Observation`, `Learning`, `ContextUpdates` confirmed at `file_store.py:2178-2203`). ✓ From 9ca55930ae7e910ac340a8b2efd78de4f79d20e9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:36:02 +0530 Subject: [PATCH 043/200] feat(notebook): Observation/Learning -> Note converters --- gently/harness/memory/notebook.py | 32 ++++++++++++++++++++++++++++++- tests/test_notebook_store.py | 32 ++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index f6e23b4d..7f3176d9 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -19,7 +19,7 @@ import yaml -from .model import Confidence +from .model import Confidence, Learning, Observation class NoteKind(str, Enum): @@ -108,6 +108,36 @@ def note_from_dict(d: dict[str, Any]) -> Note: ) +def observation_to_note(obs: Observation) -> Note: + """Bridge a legacy Observation into a notebook Note (kind=observation).""" + return Note( + id=obs.id, + kind=NoteKind.OBSERVATION, + body=obs.content, + author=Author.AGENT, + embryos=[obs.embryo_id] if obs.embryo_id else [], + sessions=[obs.session_id] if obs.session_id else [], + links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])], + artifacts=[obs.gently_refs] if obs.gently_refs else [], + created_at=obs.timestamp, + updated_at=obs.timestamp, + ) + + +def learning_to_note(learning: Learning) -> Note: + """Bridge a legacy Learning into a notebook Note (kind=finding, proposed).""" + return Note( + id=learning.id, + kind=NoteKind.FINDING, + body=learning.content, + author=Author.AGENT, + status=NoteStatus.PROPOSED, + confidence=learning.confidence, + created_at=learning.created_at, + updated_at=learning.created_at, + ) + + class NotebookStore: """File-backed store for notebook Notes. One YAML per note under notes/; flat pool, rebuildable reverse-indexes (added in Task 3).""" diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 3a7b5294..07867b5f 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -2,15 +2,17 @@ from datetime import datetime -from gently.harness.memory.model import Confidence +from gently.harness.memory.model import Confidence, Learning, Observation from gently.harness.memory.notebook import ( Author, Note, NoteKind, NoteStatus, NotebookStore, + learning_to_note, note_from_dict, note_to_dict, + observation_to_note, ) @@ -154,3 +156,31 @@ def test_supersede_marks_old_and_points_new(self, tmp_path): assert old_n.status == NoteStatus.SUPERSEDED assert old_n.superseded_by == new assert {"rel": "refines", "to": old} in new_n.links + + +class TestConverters: + def test_observation_to_note(self): + obs = Observation( + id="o1", timestamp=datetime(2026, 6, 16, 9, 0, 0), type="milestone", + content="nerve ring formed", embryo_id="e1", session_id="s1", + relates_to=["o0"], gently_refs={"kind": "projection", "t": 42}, + ) + n = observation_to_note(obs) + assert n.id == "o1" + assert n.kind == NoteKind.OBSERVATION + assert n.body == "nerve ring formed" + assert n.author == Author.AGENT + assert n.embryos == ["e1"] + assert n.sessions == ["s1"] + assert {"rel": "relates_to", "to": "o0"} in n.links + assert n.artifacts == [{"kind": "projection", "t": 42}] + assert n.created_at == datetime(2026, 6, 16, 9, 0, 0) + + def test_learning_to_note(self): + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + n = learning_to_note(lrn) + assert n.id == "l1" + assert n.kind == NoteKind.FINDING + assert n.body == "rings form by comma" + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.confidence == Confidence.HIGH From 19c2ff4d1dcfc6207a0229d10fdb3a36b04aff5b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:37:43 +0530 Subject: [PATCH 044/200] feat(notebook): FileContextStore.notebook lazy property --- gently/harness/memory/file_store.py | 11 +++++++++++ tests/test_notebook_store.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index c19f785b..71752461 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -2175,6 +2175,17 @@ def set_state(self, key: str, value: str): # Batch Updates # ================================================================== + @property + def notebook(self): + """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" + nb = getattr(self, "_notebook", None) + if nb is None: + from .notebook import NotebookStore + + nb = NotebookStore(self.agent_dir / "notebook") + self._notebook = nb + return nb + def apply_updates(self, updates: ContextUpdates): for obs in updates.new_observations: self.add_observation(obs) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 07867b5f..f0f48219 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -184,3 +184,12 @@ def test_learning_to_note(self): assert n.body == "rings form by comma" assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm assert n.confidence == Confidence.HIGH + + +class TestContextStoreNotebook: + def test_notebook_property_rooted_under_agent_dir(self, file_context_store): + nb = file_context_store.notebook + assert nb.root == file_context_store.agent_dir / "notebook" + + def test_notebook_property_is_cached(self, file_context_store): + assert file_context_store.notebook is file_context_store.notebook From 9224b0fbffd01f5574e46499fa0d737d529cad2f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:39:25 +0530 Subject: [PATCH 045/200] feat(notebook): apply_updates mirrors observations & learnings into notebook --- gently/harness/memory/file_store.py | 12 ++++++++++++ tests/test_notebook_store.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 71752461..7c153073 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -2213,6 +2213,18 @@ def apply_updates(self, updates: ContextUpdates): if updates.new_focus is not None: self.set_state("current_focus", updates.new_focus) + # Mirror new observations & learnings into the shared notebook + # (best-effort — a notebook failure never breaks the legacy write). + from .notebook import learning_to_note, observation_to_note + + try: + for obs in updates.new_observations: + self.notebook.write_note(observation_to_note(obs)) + for learning in updates.new_learnings: + self.notebook.write_note(learning_to_note(learning)) + except Exception: + logger.warning("notebook mirror failed", exc_info=True) + # ================================================================== # ML Pipelines # ================================================================== diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index f0f48219..4fdd48f7 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -193,3 +193,26 @@ def test_notebook_property_rooted_under_agent_dir(self, file_context_store): def test_notebook_property_is_cached(self, file_context_store): assert file_context_store.notebook is file_context_store.notebook + + +class TestApplyUpdatesMirror: + def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + obs = Observation(id="o1", timestamp=datetime(2026, 6, 16, 9, 0, 0), + type="milestone", content="ring formed", embryo_id="e1") + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) + + bodies = {n.body for n in cs.notebook.query_notes()} + assert "ring formed" in bodies + assert "rings form by comma" in bodies + assert cs.notebook.ids_for_embryo("e1") == ["o1"] + + def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + cs.apply_updates(ContextUpdates()) + assert cs.notebook.query_notes() == [] From 03ea7c621128679e1a966f2867646f9c5a11a35f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:44:54 +0530 Subject: [PATCH 046/200] plan: notebook read API Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-read-api.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-read-api.md diff --git a/docs/superpowers/plans/2026-06-16-notebook-read-api.md b/docs/superpowers/plans/2026-06-16-notebook-read-api.md new file mode 100644 index 00000000..f4a5f20d --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-read-api.md @@ -0,0 +1,265 @@ +# Notebook Read API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or subagent-driven-development) to implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Expose the shared notebook over HTTP so the frontend can read it — list/filter notes, fetch one note, and list inquiry-threads with counts. + +**Architecture:** A new route module `gently/ui/web/routes/notebook.py` exposing `create_router(server)` (the established pattern), reading `server.context_store.notebook` (the `NotebookStore` added in the producer-bridge plan) and serializing via `note_to_dict`. Registered in `routes/__init__.py`. Read-only; authoring/curation is a later increment. + +**Tech Stack:** FastAPI `APIRouter`, pytest + `fastapi.testclient.TestClient`, the `file_context_store` fixture (`tests/conftest.py`). + +**Out of scope:** the Notebook tab UI (next plan, browser-verified); the Agent's-View rewire; retrieval/embeddings. + +--- + +### Task 1: Route module — list & get notes + +**Files:** +- Create: `gently/ui/web/routes/notebook.py` +- Modify: `gently/ui/web/routes/__init__.py` +- Test: `tests/test_notebook_api.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_api.py +"""Tests for the notebook read API.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.notebook import Note, NoteKind, NoteStatus + + +def _make_app(context_store): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + return cs + + +class TestListNotes: + def test_no_store_available_false(self): + client = TestClient(_make_app(None)) + data = client.get("/api/notebook/notes").json() + assert data == {"available": False, "notes": []} + + def test_list_all(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes").json() + assert data["available"] is True + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_filter_by_kind(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=finding").json() + assert {n["id"] for n in data["notes"]} == {"f1"} + + def test_filter_by_strain(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?strain=N2").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_invalid_kind_is_ignored(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=bogus").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + +class TestGetNote: + def test_get_existing(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes/o1").json() + assert data["id"] == "o1" + assert data["body"] == "ring formed" + + def test_get_missing_404(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + resp = client.get("/api/notebook/notes/nope") + assert resp.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_api.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.ui.web.routes.notebook'` + +- [ ] **Step 3: Write minimal implementation** + +Create `gently/ui/web/routes/notebook.py`: + +```python +"""Notebook (shared lab notebook) read routes. + +Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge. +Read-only here; authoring/curation come in a later increment. +""" + +from fastapi import APIRouter, HTTPException + +from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict + + +def _coerce(enum_cls, value): + """Parse a query-param string into an enum; invalid/None → None (no filter).""" + if value is None: + return None + try: + return enum_cls(value) + except ValueError: + return None + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _nb(): + cs = getattr(server, "context_store", None) + return cs.notebook if cs is not None else None + + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + return {"available": True, "notes": [note_to_dict(n) for n in notes]} + + @router.get("/api/notebook/notes/{note_id}") + async def get_note(note_id: str): + nb = _nb() + if nb is None: + raise HTTPException(status_code=404, detail="notebook unavailable") + note = nb.get_note(note_id) + if note is None: + raise HTTPException(status_code=404, detail="note not found") + return note_to_dict(note) + + return router +``` + +Then register it in `gently/ui/web/routes/__init__.py`. Add the import after the `images` import line: + +```python +from .notebook import create_router as create_notebook_router +``` + +And add `create_notebook_router,` to the factory tuple in `register_all_routes` (after `create_context_router,`): + +```python + create_context_router, + create_notebook_router, + ): +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_api.py -v` +Expected: PASS (7 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py gently/ui/web/routes/__init__.py tests/test_notebook_api.py +git commit -m "feat(notebook): read API — GET /api/notebook/notes + /notes/{id}" +``` + +--- + +### Task 2: `GET /api/notebook/threads` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class TestThreads: + def test_no_store(self): + client = TestClient(_make_app(None)) + assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []} + + def test_thread_counts(self, file_context_store): + cs = file_context_store + nb = cs.notebook + nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"])) + nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"])) + client = TestClient(_make_app(cs)) + data = client.get("/api/notebook/threads").json() + assert data["available"] is True + assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_api.py::TestThreads -v` +Expected: FAIL — 404 (route not defined) + +- [ ] **Step 3: Write minimal implementation** + +Add this endpoint inside `create_router`, just before `return router`: + +```python + @router.get("/api/notebook/threads") + async def list_threads(): + nb = _nb() + if nb is None: + return {"available": False, "threads": []} + counts: dict[str, int] = {} + for n in nb.query_notes(): + for t in n.threads: + counts[t] = counts.get(t, 0) + 1 + threads = [{"id": t, "count": c} for t, c in sorted(counts.items())] + return {"available": True, "threads": threads} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): read API — GET /api/notebook/threads with counts" +``` + +--- + +## Self-Review + +**Spec coverage:** read surface API for the notebook (design increment 1c, backend half) — query notes by kind/author/status/scope, fetch one, list threads. ✓ Reuses `NotebookStore.query_notes` + `note_to_dict` from the foundation. ✓ Follows `create_router(server)` + `server.context_store` convention (`context.py`). ✓ +**Placeholder scan:** none — complete code + commands. ✓ +**Type consistency:** `create_router`, `note_to_dict`, `NoteKind`/`Author`/`NoteStatus`, `server.context_store.notebook`, `nb.query_notes`/`get_note` all match the foundation + producer-bridge modules. Registration matches the existing tuple in `routes/__init__.py`. ✓ From 7d51e20a8fb93f04677b61849e1b4ae9aab34635 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:47:34 +0530 Subject: [PATCH 047/200] =?UTF-8?q?feat(notebook):=20read=20API=20?= =?UTF-8?q?=E2=80=94=20GET=20/api/notebook/notes=20+=20/notes/{id}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/notebook.py | 61 ++++++++++++++++++++++++++++ tests/test_notebook_api.py | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 gently/ui/web/routes/notebook.py create mode 100644 tests/test_notebook_api.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index bdbf3db7..9f8cd903 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .images import create_router as create_images_router +from .notebook import create_router as create_notebook_router from .pages import create_router as create_pages_router from .sessions import create_router as create_sessions_router from .volumes import create_router as create_volumes_router @@ -35,6 +36,7 @@ def register_all_routes(server): create_agent_ws_router, create_chat_router, create_context_router, + create_notebook_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py new file mode 100644 index 00000000..a285a3d4 --- /dev/null +++ b/gently/ui/web/routes/notebook.py @@ -0,0 +1,61 @@ +"""Notebook (shared lab notebook) read routes. + +Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge. +Read-only here; authoring/curation come in a later increment. +""" + +from fastapi import APIRouter, HTTPException + +from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict + + +def _coerce(enum_cls, value): + """Parse a query-param string into an enum; invalid/None → None (no filter).""" + if value is None: + return None + try: + return enum_cls(value) + except ValueError: + return None + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _nb(): + cs = getattr(server, "context_store", None) + return cs.notebook if cs is not None else None + + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + return {"available": True, "notes": [note_to_dict(n) for n in notes]} + + @router.get("/api/notebook/notes/{note_id}") + async def get_note(note_id: str): + nb = _nb() + if nb is None: + raise HTTPException(status_code=404, detail="notebook unavailable") + note = nb.get_note(note_id) + if note is None: + raise HTTPException(status_code=404, detail="note not found") + return note_to_dict(note) + + return router diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py new file mode 100644 index 00000000..a6e088f1 --- /dev/null +++ b/tests/test_notebook_api.py @@ -0,0 +1,69 @@ +"""Tests for the notebook read API.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.notebook import Note, NoteKind, NoteStatus + + +def _make_app(context_store): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + return cs + + +class TestListNotes: + def test_no_store_available_false(self): + client = TestClient(_make_app(None)) + data = client.get("/api/notebook/notes").json() + assert data == {"available": False, "notes": []} + + def test_list_all(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes").json() + assert data["available"] is True + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_filter_by_kind(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=finding").json() + assert {n["id"] for n in data["notes"]} == {"f1"} + + def test_filter_by_strain(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?strain=N2").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_invalid_kind_is_ignored(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=bogus").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + +class TestGetNote: + def test_get_existing(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes/o1").json() + assert data["id"] == "o1" + assert data["body"] == "ring formed" + + def test_get_missing_404(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + resp = client.get("/api/notebook/notes/nope") + assert resp.status_code == 404 From 1a0121df46e2e420ac74e4e147caed42cff84c59 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:48:56 +0530 Subject: [PATCH 048/200] =?UTF-8?q?feat(notebook):=20read=20API=20?= =?UTF-8?q?=E2=80=94=20GET=20/api/notebook/threads=20with=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/notebook.py | 12 ++++++++++++ tests/test_notebook_api.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index a285a3d4..a0176630 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -58,4 +58,16 @@ async def get_note(note_id: str): raise HTTPException(status_code=404, detail="note not found") return note_to_dict(note) + @router.get("/api/notebook/threads") + async def list_threads(): + nb = _nb() + if nb is None: + return {"available": False, "threads": []} + counts: dict[str, int] = {} + for n in nb.query_notes(): + for t in n.threads: + counts[t] = counts.get(t, 0) + 1 + threads = [{"id": t, "count": c} for t, c in sorted(counts.items())] + return {"available": True, "threads": threads} + return router diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index a6e088f1..7510d5b7 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -67,3 +67,19 @@ def test_get_missing_404(self, file_context_store): client = TestClient(_make_app(_seed(file_context_store))) resp = client.get("/api/notebook/notes/nope") assert resp.status_code == 404 + + +class TestThreads: + def test_no_store(self): + client = TestClient(_make_app(None)) + assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []} + + def test_thread_counts(self, file_context_store): + cs = file_context_store + nb = cs.notebook + nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"])) + nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"])) + client = TestClient(_make_app(cs)) + data = client.get("/api/notebook/threads").json() + assert data["available"] is True + assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}] From 0760578df5ae820b8b9c39ce2089120d352e497d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 17:58:13 +0530 Subject: [PATCH 049/200] feat(notebook): Notebook tab (read view) in LIBRARY rail Thread rail + kind filter + note cards (color-coded by kind, with author, status, and scope chips), consuming /api/notebook. Wired into the v2 rail and the legacy tab bar, lazy-init via switchTab, live-refresh on CONTEXT_UPDATED. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/notebook.css | 62 +++++++++++ gently/ui/web/static/js/app.js | 7 +- gently/ui/web/static/js/notebook.js | 142 ++++++++++++++++++++++++++ gently/ui/web/static/js/utils.js | 2 +- gently/ui/web/templates/_navbar.html | 2 + gently/ui/web/templates/index.html | 22 ++++ 6 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 gently/ui/web/static/css/notebook.css create mode 100644 gently/ui/web/static/js/notebook.js diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css new file mode 100644 index 00000000..14810510 --- /dev/null +++ b/gently/ui/web/static/css/notebook.css @@ -0,0 +1,62 @@ +/* ── Notebook tab (LIBRARY) ────────────────────────────────────────────── + The reading room for the shared lab notebook: thread rail + kind filter + + note cards. Tokens reuse the app theme (--accent, --accent-green, --border …). */ + +.nb-container { max-width: 1100px; margin: 0 auto; padding: 24px 28px; } + +.nb-header { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; margin-bottom: 18px; flex-wrap: wrap; +} +.nb-title { font-size: 22px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin: 0; } + +.nb-kinds { display: flex; gap: 6px; } +.nb-kind { + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 6px 13px; font: inherit; font-size: 13px; cursor: pointer; + transition: border-color .15s, color .15s, background .15s; +} +.nb-kind:hover { color: var(--text, #0f172a); border-color: var(--border-strong, #cbd5e1); } +.nb-kind.active { background: var(--accent, #2f6df6); border-color: var(--accent, #2f6df6); color: #fff; } + +.nb-body-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: start; } +@media (max-width: 760px) { .nb-body-wrap { grid-template-columns: 1fr; } } + +/* thread rail (the inquiry spine) */ +.nb-threads { display: flex; flex-direction: column; gap: 4px; position: sticky; top: 12px; } +.nb-thread { + text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; + color: var(--text-secondary, #475569); padding: 8px 11px; border-radius: 9px; + transition: background .15s, color .15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.nb-thread:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); } +.nb-thread.active { background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); font-weight: 600; } + +/* notes */ +.nb-notes { display: flex; flex-direction: column; gap: 12px; } +.nb-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; padding: 28px 4px; } + +.nb-card { + border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 14px 16px; + background: var(--bg-card, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); +} +.nb-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.nb-badge { + font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + padding: 2px 9px; border-radius: 999px; color: #fff; background: var(--text-muted, #94a3b8); +} +.nb-badge.nb-k-obs { background: var(--accent, #2f6df6); } +.nb-badge.nb-k-find { background: var(--accent-green, #16a34a); } +.nb-badge.nb-k-q { background: #d97706; } +.nb-author { font-size: 12px; color: var(--text-secondary, #475569); } +.nb-status { + margin-left: auto; font-size: 11px; color: var(--text-muted, #94a3b8); + text-transform: uppercase; letter-spacing: .05em; +} +.nb-body-text { font-size: 14px; line-height: 1.5; color: var(--text, #0f172a); white-space: pre-wrap; } + +.nb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.nb-chip { + font-size: 11.5px; color: var(--text-secondary, #475569); + background: var(--bg-hover, #f1f5f9); border-radius: 7px; padding: 2px 8px; +} diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index a8c6450c..41bad885 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -100,6 +100,11 @@ function switchTab(tabName) { ExperimentOverview.init(); } + // Lazy-init Notebook tab + if (tabName === TABS.NOTEBOOK && typeof NotebookApp !== 'undefined') { + NotebookApp.init(); + } + // Update statusbar for context updateStatusbar(); } @@ -651,7 +656,7 @@ document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.slice(1); // remove # if (hash) { const [tab, param] = hash.split(':'); - if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) { + if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK) { switchTab(tab); if (tab === TABS.PLANS && param && typeof openCampaign === 'function') { setTimeout(() => openCampaign(param), 200); diff --git a/gently/ui/web/static/js/notebook.js b/gently/ui/web/static/js/notebook.js new file mode 100644 index 00000000..2fe94c6c --- /dev/null +++ b/gently/ui/web/static/js/notebook.js @@ -0,0 +1,142 @@ +/** + * NotebookApp — the LIBRARY "Notebook" tab. + * + * The reading room for the shared lab notebook: a thread rail (the inquiry + * spine) + kind filter, rendering Notes from the read API (/api/notebook). + * Read-only for now; authoring/curation arrive in a later increment. + */ +const NotebookApp = (() => { + let inited = false; + let kindFilter = ''; // '' | observation | finding | question + let threadFilter = ''; // '' = all notes + + const $ = (id) => document.getElementById(id); + + async function fetchJSON(url) { + try { + const r = await fetch(url); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + + function kindMeta(kind) { + return ({ + observation: { label: 'Observation', cls: 'nb-k-obs' }, + finding: { label: 'Finding', cls: 'nb-k-find' }, + question: { label: 'Question', cls: 'nb-k-q' }, + })[kind] || { label: kind || 'note', cls: '' }; + } + + async function loadThreads() { + const rail = $('nb-threads'); + if (!rail) return; + const data = await fetchJSON('/api/notebook/threads'); + const threads = (data && data.threads) || []; + rail.innerHTML = ''; + const mk = (id, label, count, active) => { + const b = document.createElement('button'); + b.className = 'nb-thread' + (active ? ' active' : ''); + b.textContent = label + (count != null ? ` ${count}` : ''); + b.addEventListener('click', () => { threadFilter = id; loadThreads(); loadNotes(); }); + return b; + }; + rail.appendChild(mk('', 'All notes', null, threadFilter === '')); + threads.forEach(t => rail.appendChild(mk(t.id, t.id, t.count, threadFilter === t.id))); + } + + function card(n) { + const km = kindMeta(n.kind); + const el = document.createElement('div'); + el.className = 'nb-card'; + + const head = document.createElement('div'); + head.className = 'nb-card-head'; + const badge = document.createElement('span'); + badge.className = 'nb-badge ' + km.cls; + badge.textContent = km.label; + const author = document.createElement('span'); + author.className = 'nb-author'; + author.textContent = n.author || ''; + const status = document.createElement('span'); + status.className = 'nb-status'; + status.textContent = n.status || ''; + head.append(badge, author, status); + + const body = document.createElement('div'); + body.className = 'nb-body-text'; + body.textContent = n.title || n.body || ''; + + el.append(head, body); + + const chips = [] + .concat((n.strains || []).map(s => '🧬 ' + s)) + .concat((n.embryos || []).map(e => '◌ ' + e)) + .concat((n.threads || []).map(t => '# ' + t)); + if (chips.length) { + const row = document.createElement('div'); + row.className = 'nb-chips'; + chips.forEach(text => { + const c = document.createElement('span'); + c.className = 'nb-chip'; + c.textContent = text; + row.appendChild(c); + }); + el.appendChild(row); + } + return el; + } + + async function loadNotes() { + const list = $('nb-notes'); + if (!list) return; + const params = new URLSearchParams(); + if (kindFilter) params.set('kind', kindFilter); + if (threadFilter) params.set('thread', threadFilter); + const qs = params.toString(); + const data = await fetchJSON('/api/notebook/notes' + (qs ? `?${qs}` : '')); + if (!data || data.available === false) { + list.innerHTML = '
Notebook unavailable.
'; + return; + } + const notes = data.notes || []; + if (!notes.length) { + list.innerHTML = + '
No notes yet — the notebook fills as the agent ' + + 'records observations, findings, and open questions.
'; + return; + } + list.innerHTML = ''; + notes.forEach(n => list.appendChild(card(n))); + } + + function setupFilters() { + document.querySelectorAll('#notebook-content [data-nb-kind]').forEach(btn => { + btn.addEventListener('click', () => { + kindFilter = btn.dataset.nbKind; + document.querySelectorAll('#notebook-content [data-nb-kind]') + .forEach(b => b.classList.toggle('active', b === btn)); + loadNotes(); + }); + }); + } + + function refresh() { loadThreads(); loadNotes(); } + + function init() { + if (inited) { refresh(); return; } + inited = true; + setupFilters(); + refresh(); + // Notebook writes ride the CONTEXT_UPDATED event — live-refresh if visible. + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONTEXT_UPDATED', () => { + if (typeof state !== 'undefined' && state.tab === 'notebook') refresh(); + }); + } + } + + return { init }; +})(); diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js index 8b4a3d79..b321506c 100644 --- a/gently/ui/web/static/js/utils.js +++ b/gently/ui/web/static/js/utils.js @@ -3,7 +3,7 @@ // ══════════════════════════════════════════════════════════ // Tab and view name constants -const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' }; +const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook' }; /** * Extract the XY firmware fence (the addressable stage box) from a device-state diff --git a/gently/ui/web/templates/_navbar.html b/gently/ui/web/templates/_navbar.html index 33d5f675..ed02ba49 100644 --- a/gently/ui/web/templates/_navbar.html +++ b/gently/ui/web/templates/_navbar.html @@ -23,6 +23,7 @@
Plans
Sessions
+
Notebook
{% else %} {# Standalone pages — all tabs link back to the SPA #}
Home @@ -34,5 +35,6 @@
Plans Sessions + Notebook {% endif %} diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index cd0b1fba..242e5441 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -18,6 +18,7 @@ + {% if ux_v2 %} @@ -122,6 +123,7 @@

Take a quick look

Library
+
System
@@ -409,6 +411,25 @@

Experiment

{% include '_sessions_panel.html' %}
+ +
+
+
+

Notebook

+
+ + + + +
+
+
+ +
+
+
+
+
@@ -785,6 +806,7 @@

Properties

+ From 1c87896382ecbce01dee31eb78a1f23c7821c7b1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 18:02:38 +0530 Subject: [PATCH 050/200] plan: notebook live edge (Agent's view recent notes) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-live-edge.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-live-edge.md diff --git a/docs/superpowers/plans/2026-06-16-notebook-live-edge.md b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md new file mode 100644 index 00000000..9265d769 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md @@ -0,0 +1,188 @@ +# Notebook Live Edge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the Home "Agent's view" panel surface recent notebook activity — the ambient "live edge" of the notebook (design §3, two-faced presentation), complementing the Notebook tab (the reading room). + +**Architecture:** Add an optional `limit` to the notes read API (TDD). Extend `context-surface.js` to also fetch recent notes and render a "From the notebook" section whose rows click through to the Notebook tab. Reuse the existing `cx-dot` colors (amber/blue/green) for kinds — no new CSS. + +**Tech Stack:** FastAPI, pytest + TestClient (venv), vanilla JS. + +**Out of scope:** retrieval/"Ask the notebook" (next increment); proactive surfacing. + +--- + +### Task 1: `limit` param on `GET /api/notebook/notes` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class TestLimit: + def test_limit_returns_newest(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?limit=1").json() + assert len(data["notes"]) == 1 # newest-first, capped +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestLimit -v` +Expected: FAIL — returns 2 notes, not 1. + +- [ ] **Step 3: Write minimal implementation** + +In `gently/ui/web/routes/notebook.py`, add a `limit` param to `list_notes` and slice. Replace the `list_notes` signature and the final return: + +```python + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + limit: int | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + if limit is not None and limit >= 0: + notes = notes[:limit] + return {"available": True, "notes": [note_to_dict(n) for n in notes]} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): limit param on GET /api/notebook/notes" +``` + +--- + +### Task 2: "From the notebook" section in the Agent's-view panel + +**Files:** +- Modify: `gently/ui/web/static/js/context-surface.js` + +Verification is browser-based (chrome-devtools), not pytest. + +- [ ] **Step 1: Extend `fetchAndRender` to also pull recent notes** + +Replace `fetchAndRender` with a version that fetches both endpoints and passes notes to `render`: + +```javascript + async function fetchAndRender() { + if (!el || loading) return; + loading = true; + try { + const [ctx, nb] = await Promise.all([ + fetch('/api/context').then(r => r.json()).catch(() => ({})), + fetch('/api/notebook/notes?limit=5').then(r => r.json()).catch(() => ({})), + ]); + render(ctx || {}, (nb && nb.notes) || []); + } catch (e) { /* keep last render */ } + finally { loading = false; } + } +``` + +- [ ] **Step 2: Render the notebook section** + +Replace `render(data)` with `render(data, notes)`. Add the notebook section and include notes in the empty-state check. Replace the whole `render` function body: + +```javascript + function render(data, notes) { + if (!el) return; + notes = notes || []; + const hc = hasControl(); + const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || []; + el.classList.remove('hidden'); + if (!questions.length && !watchpoints.length && !expectations.length && !notes.length) { + el.innerHTML = '
Agent’s view
' + + '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; + return; + } + + const qHtml = questions.map(it => ` +
+ + ${esc(it.content)} + ${hc ? '' : ''} + ${hc ? '' : ''} +
`).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + section('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); + wire(); + } +``` + +- [ ] **Step 3: Make notebook rows click through to the Notebook tab** + +In `wire()`, after the existing `.cx-item` loop, add a handler for note rows (append inside `wire`, before its closing brace): + +```javascript + el.querySelectorAll('.cx-note').forEach(row => { + row.style.cursor = 'pointer'; + row.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab('notebook'); + }); + }); +``` + +- [ ] **Step 4: Verify in the browser** + +Restart the server, open `http://localhost:8080`, confirm the Home "Agent's view" panel shows a "From the notebook" section with recent notes, and clicking a row switches to the Notebook tab. Use chrome-devtools (navigate, evaluate_script to click, take_screenshot, list_console_messages → zero errors). + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/static/js/context-surface.js +git commit -m "feat(notebook): Agent's-view live edge — recent notes section -> Notebook tab" +``` + +--- + +## Self-Review +**Spec coverage:** design §3 two-faced presentation — the ambient live edge now surfaces recent notebook notes on Home, clicking through to the tab. ✓ Reuses `/api/notebook/notes` (+ new `limit`) and existing `cx-dot` colors. ✓ +**Placeholder scan:** none. ✓ +**Type consistency:** `render(data, notes)`, `dotFor`, `limit` param, `switchTab('notebook')` all consistent; kinds map to existing cx classes. ✓ From 9b28fa8b3d5a3b1e4fc2c733d274419953b2b306 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 18:04:31 +0530 Subject: [PATCH 051/200] feat(notebook): limit param on GET /api/notebook/notes --- gently/ui/web/routes/notebook.py | 3 +++ tests/test_notebook_api.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index a0176630..b1585ff8 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -34,6 +34,7 @@ async def list_notes( strain: str | None = None, embryo: str | None = None, thread: str | None = None, + limit: int | None = None, ): nb = _nb() if nb is None: @@ -46,6 +47,8 @@ async def list_notes( embryo=embryo, thread=thread, ) + if limit is not None and limit >= 0: + notes = notes[:limit] return {"available": True, "notes": [note_to_dict(n) for n in notes]} @router.get("/api/notebook/notes/{note_id}") diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index 7510d5b7..b24abbd9 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -83,3 +83,10 @@ def test_thread_counts(self, file_context_store): data = client.get("/api/notebook/threads").json() assert data["available"] is True assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}] + + +class TestLimit: + def test_limit_returns_newest(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?limit=1").json() + assert len(data["notes"]) == 1 # newest-first, capped From b6439724442c54e1a7a821ebb19bd85b3447bcd6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:01:24 +0530 Subject: [PATCH 052/200] =?UTF-8?q?feat(notebook):=20Agent's-view=20live?= =?UTF-8?q?=20edge=20=E2=80=94=20recent=20notes=20section=20=E2=86=92=20No?= =?UTF-8?q?tebook=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/static/js/context-surface.js | 33 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js index 2e10000d..2c11e27e 100644 --- a/gently/ui/web/static/js/context-surface.js +++ b/gently/ui/web/static/js/context-surface.js @@ -19,23 +19,29 @@ const ContextSurface = (() => { async function fetchAndRender() { if (!el || loading) return; loading = true; - try { render(await (await fetch('/api/context')).json()); } - catch (e) { /* keep last render */ } + try { + const [ctx, nb] = await Promise.all([ + fetch('/api/context').then(r => r.json()).catch(() => ({})), + fetch('/api/notebook/notes?limit=5').then(r => r.json()).catch(() => ({})), + ]); + render(ctx || {}, (nb && nb.notes) || []); + } catch (e) { /* keep last render */ } finally { loading = false; } } function section(label, html) { return html ? `
${label}
${html}
` : ''; } - function render(data) { + function render(data, notes) { if (!el) return; + notes = notes || []; const hc = hasControl(); const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || []; el.classList.remove('hidden'); - if (!questions.length && !watchpoints.length && !expectations.length) { + if (!questions.length && !watchpoints.length && !expectations.length && !notes.length) { // Show an empty-state rather than vanishing, so the surface is // discoverable before the agent has formed any beliefs. el.innerHTML = '
Agent’s view
' + - '
Nothing yet — the agent’s expectations, watchpoints, and open questions appear here as it works.
'; + '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; return; } @@ -59,8 +65,17 @@ const ContextSurface = (() => { ${hc ? '' : ''}
`).join(''); + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + el.innerHTML = '
Agent’s view
' + - section('Open questions', qHtml) + section('Watching', wHtml) + section('Expectations', eHtml); + section('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); wire(); } @@ -83,6 +98,12 @@ const ContextSurface = (() => { actBtn.addEventListener('click', () => resolve(kind, id, { status: 'confirmed' })); } }); + el.querySelectorAll('.cx-note').forEach(row => { + row.style.cursor = 'pointer'; + row.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab('notebook'); + }); + }); } async function resolve(kind, id, body) { From 814e322f9722cffdf58d3a342d965effd4181e9d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:17:39 +0530 Subject: [PATCH 053/200] plan: ask the notebook (increment 2 backend) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-17-notebook-ask.md | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-notebook-ask.md diff --git a/docs/superpowers/plans/2026-06-17-notebook-ask.md b/docs/superpowers/plans/2026-06-17-notebook-ask.md new file mode 100644 index 00000000..c03ea526 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-notebook-ask.md @@ -0,0 +1,408 @@ +# "Ask the Notebook" Implementation Plan (Increment 2, backend) + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Let the notebook be *reasoned with* — given a question (+ optional scope), retrieve relevant Notes, ask Claude to synthesize a **grounded, cited** answer, and return it as a validated structured object. + +**Architecture:** A new module `gently/harness/memory/notebook_ask.py`: structural retrieval (`select_notes`) + a forced-`tool_choice` synthesis call (`answer_question`) that reuses gently's conventions — `anthropic.AsyncAnthropic` (per `chat.py:198`), `settings.models.main` (Opus 4.8), structured output via a pinned tool (per `verifier.py`), and **no self-rated confidence** (per the lab rule). A `POST /api/notebook/ask` route wires retrieval → synthesis. The Claude client is injected so everything is unit-testable with a fake. + +**Tech Stack:** Python 3.11, `anthropic` SDK, FastAPI, pytest + TestClient (venv). + +**Out of scope (later plans):** embeddings/semantic recall (structural-only here); the "Ask" UI box on the Notebook tab; proactive surfacing. + +--- + +### Task 1: Structural retrieval — `select_notes` + +**Files:** +- Create: `gently/harness/memory/notebook_ask.py` +- Test: `tests/test_notebook_ask.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_ask.py +"""Tests for 'Ask the notebook' — retrieval + grounded synthesis.""" + +from gently.harness.memory.notebook import Note, NoteKind +from gently.harness.memory.notebook_ask import select_notes + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) + return nb + + +class TestSelectNotes: + def test_scope_by_thread(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, thread="t1")} + assert ids == {"o1", "f1"} + + def test_scope_by_strain(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, strain="OH904")} + assert ids == {"x1"} + + def test_no_scope_returns_recent_capped(self, file_context_store): + nb = _seed(file_context_store) + notes = select_notes(nb, limit=2) + assert len(notes) == 2 # newest-first, capped +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook_ask'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/harness/memory/notebook_ask.py +"""Ask the notebook — retrieve relevant Notes and synthesize a grounded, +cited answer with Claude. Structural retrieval only (semantic recall is a +later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4. +""" + +from __future__ import annotations + +from .notebook import Note, NotebookStore + + +def select_notes( + store: NotebookStore, + *, + thread: str | None = None, + strain: str | None = None, + limit: int = 12, +) -> list[Note]: + """Structural narrowing: scope by thread/strain when given, else recent. + Returns newest-first, capped at `limit`.""" + notes = store.query_notes(thread=thread, strain=strain) + return notes[:limit] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py +git commit -m "feat(notebook): select_notes — structural retrieval for ask" +``` + +--- + +### Task 2: Grounded synthesis — `answer_question` (forced tool) + +**Files:** +- Modify: `gently/harness/memory/notebook_ask.py` +- Test: `tests/test_notebook_ask.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_ask.py +import asyncio + +from gently.harness.memory.notebook_ask import ASK_TOOL, answer_question, build_ask_messages + + +class _FakeBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _FakeResp: + def __init__(self, inp): + self.content = [_FakeBlock(inp)] + self.stop_reason = "tool_use" + + +class _FakeMessages: + def __init__(self, captured, inp): + self._captured, self._inp = captured, inp + + async def create(self, **kwargs): + self._captured.update(kwargs) + return _FakeResp(self._inp) + + +class _FakeClient: + def __init__(self, inp): + self.captured = {} + self.messages = _FakeMessages(self.captured, inp) + + +class TestAnswerQuestion: + def test_build_messages_embeds_note_ids(self): + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + msgs = build_ask_messages("what formed?", notes) + text = msgs[0]["content"] + assert "o1" in text and "ring formed" in text and "what formed?" in text + + def test_answer_returns_structured_and_forces_tool(self): + canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = _FakeClient(canned) + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + out = asyncio.run(answer_question(client, "m", "what formed?", notes)) + assert out == canned + # tool_choice is pinned to the ask tool (forced structured output) + assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]} + assert client.captured["model"] == "m" + + def test_answer_no_notes_short_circuits_without_api(self): + client = _FakeClient({"should": "not be used"}) + out = asyncio.run(answer_question(client, "m", "anything?", [])) + assert out["coverage"] == "not_in_notebook" + assert client.captured == {} # no API call when nothing to ground on +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestAnswerQuestion -v` +Expected: FAIL — `ImportError: cannot import name 'ASK_TOOL'` + +- [ ] **Step 3: Write minimal implementation** + +Append to `gently/harness/memory/notebook_ask.py`: + +```python +# ASK_TOOL pins the structured output. No confidence field — we don't ask the +# model to self-rate (lab rule); "coverage" is a factual grounding classification. +ASK_TOOL = { + "name": "answer_from_notebook", + "description": "Return a grounded answer built ONLY from the provided notebook entries.", + "input_schema": { + "type": "object", + "properties": { + "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."}, + "points": { + "type": "array", + "description": "Supporting points, each citing the note ids it rests on.", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "note_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text", "note_ids"], + }, + }, + "suggested_next": { + "type": "array", + "items": {"type": "string"}, + "description": "Concrete next experiments/moves if the question asks what to do; else empty.", + }, + "coverage": { + "type": "string", + "enum": ["covered", "partial", "not_in_notebook"], + "description": "How well the provided notes cover the question.", + }, + }, + "required": ["answer", "points", "coverage"], + }, +} + +_SYSTEM = ( + "You reason over a shared lab notebook. Answer ONLY from the notebook entries " + "provided — every claim must cite the note id(s) it rests on. If the notes do " + "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. " + "Never invent facts not in the notes. Call the answer_from_notebook tool." +) + + +def _render_notes(notes: list[Note]) -> str: + lines = [] + for n in notes: + scope = [] + if n.strains: + scope.append("strains=" + ",".join(n.strains)) + if n.embryos: + scope.append("embryos=" + ",".join(n.embryos)) + tag = f" [{'; '.join(scope)}]" if scope else "" + lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}") + return "\n".join(lines) + + +def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: + body = ( + "Notebook entries:\n" + + _render_notes(notes) + + f"\n\nQuestion: {question}\n\n" + "Answer using only these entries, citing note ids." + ) + return [{"role": "user", "content": body}] + + +async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict: + """Force the ask tool and return its validated input dict. Short-circuits + (no API call) when there are no notes to ground on.""" + if not notes: + return { + "answer": "The notebook doesn't cover this yet.", + "points": [], + "suggested_next": [], + "coverage": "not_in_notebook", + } + resp = await client.messages.create( + model=model, + max_tokens=2048, + system=_SYSTEM, + tools=[ASK_TOOL], + tool_choice={"type": "tool", "name": ASK_TOOL["name"]}, + messages=build_ask_messages(question, notes), + ) + for block in resp.content: + if getattr(block, "type", None) == "tool_use": + return block.input + return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py +git commit -m "feat(notebook): answer_question — forced-tool grounded synthesis" +``` + +--- + +### Task 3: `POST /api/notebook/ask` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class _AskBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _AskResp: + def __init__(self, inp): + self.content = [_AskBlock(inp)] + self.stop_reason = "tool_use" + + +class _AskMessages: + def __init__(self, inp): + self._inp = inp + + async def create(self, **kwargs): + return _AskResp(self._inp) + + +class _AskClient: + def __init__(self, inp): + self.messages = _AskMessages(inp) + + +def _make_app_with_client(context_store, client): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + server.claude_async = client + app.include_router(create_router(server)) + return app + + +class TestAsk: + def test_ask_returns_grounded_answer(self, file_context_store): + cs = _seed(file_context_store) + canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = TestClient(_make_app_with_client(cs, _AskClient(canned))) + resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) + assert resp.status_code == 200 + assert resp.json()["coverage"] == "covered" + + def test_ask_no_store(self): + client = TestClient(_make_app(None)) + resp = client.post("/api/notebook/ask", json={"question": "x"}) + assert resp.json() == {"available": False} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestAsk -v` +Expected: FAIL — 404/405 (route not defined) + +- [ ] **Step 3: Write minimal implementation** + +In `gently/ui/web/routes/notebook.py`, add `Body` to the fastapi import and add the route inside `create_router`, before `return router`: + +```python + @router.post("/api/notebook/ask") + async def ask( + question: str = Body(..., embed=True), + thread: str | None = Body(None, embed=True), + strain: str | None = Body(None, embed=True), + ): + nb = _nb() + if nb is None: + return {"available": False} + from gently.harness.memory.notebook_ask import answer_question, select_notes + from gently.settings import settings + + notes = select_notes(nb, thread=thread, strain=strain) + client = getattr(server, "claude_async", None) + if client is None: + import anthropic + + client = anthropic.AsyncAnthropic() + result = await answer_question(client, settings.models.main, question, notes) + result["available"] = True + result["note_ids"] = [n.id for n in notes] + return result +``` + +Update the import line at the top of the file: + +```python +from fastapi import APIRouter, Body, HTTPException +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): POST /api/notebook/ask — grounded notebook Q&A" +``` + +--- + +## Self-Review +**Spec coverage (design §4):** structural retrieval (`select_notes`) → grounded synthesis (`answer_question`, forced tool, cited, "not_in_notebook" valid) → endpoint. ✓ No self-rated confidence (`coverage` is grounding, not correctness-confidence). ✓ Reuses `settings.models.main`, `anthropic.AsyncAnthropic`, the `verifier.py` forced-tool pattern. ✓ Client injected → unit-testable without real API. ✓ +**Deferred (correct):** embeddings/semantic recall; the "Ask" UI; proactive surfacing. +**Placeholder scan:** none — complete code + commands. ✓ +**Type consistency:** `select_notes`, `answer_question`, `ASK_TOOL`, `build_ask_messages` named consistently across tasks/tests; route uses `server.claude_async` (tests inject) with a real `AsyncAnthropic` fallback. ✓ From 8928084f513916f7378f52418a10533fa9973024 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:19:33 +0530 Subject: [PATCH 054/200] =?UTF-8?q?feat(notebook):=20ask=20backend=20?= =?UTF-8?q?=E2=80=94=20select=5Fnotes=20retrieval=20+=20forced-tool=20grou?= =?UTF-8?q?nded=20synthesis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/harness/memory/notebook_ask.py | 112 ++++++++++++++++++++++++++ tests/test_notebook_ask.py | 87 ++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 gently/harness/memory/notebook_ask.py create mode 100644 tests/test_notebook_ask.py diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py new file mode 100644 index 00000000..acfed322 --- /dev/null +++ b/gently/harness/memory/notebook_ask.py @@ -0,0 +1,112 @@ +"""Ask the notebook — retrieve relevant Notes and synthesize a grounded, +cited answer with Claude. Structural retrieval only (semantic recall is a +later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4. +""" + +from __future__ import annotations + +from .notebook import Note, NotebookStore + + +def select_notes( + store: NotebookStore, + *, + thread: str | None = None, + strain: str | None = None, + limit: int = 12, +) -> list[Note]: + """Structural narrowing: scope by thread/strain when given, else recent. + Returns newest-first, capped at ``limit``.""" + notes = store.query_notes(thread=thread, strain=strain) + return notes[:limit] + + +# ASK_TOOL pins the structured output. No confidence field — we don't ask the +# model to self-rate (lab rule); "coverage" is a factual grounding classification. +ASK_TOOL = { + "name": "answer_from_notebook", + "description": "Return a grounded answer built ONLY from the provided notebook entries.", + "input_schema": { + "type": "object", + "properties": { + "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."}, + "points": { + "type": "array", + "description": "Supporting points, each citing the note ids it rests on.", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "note_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text", "note_ids"], + }, + }, + "suggested_next": { + "type": "array", + "items": {"type": "string"}, + "description": "Concrete next experiments/moves if the question asks what to do; else empty.", + }, + "coverage": { + "type": "string", + "enum": ["covered", "partial", "not_in_notebook"], + "description": "How well the provided notes cover the question.", + }, + }, + "required": ["answer", "points", "coverage"], + }, +} + +_SYSTEM = ( + "You reason over a shared lab notebook. Answer ONLY from the notebook entries " + "provided — every claim must cite the note id(s) it rests on. If the notes do " + "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. " + "Never invent facts not in the notes. Call the answer_from_notebook tool." +) + + +def _render_notes(notes: list[Note]) -> str: + lines = [] + for n in notes: + scope = [] + if n.strains: + scope.append("strains=" + ",".join(n.strains)) + if n.embryos: + scope.append("embryos=" + ",".join(n.embryos)) + tag = f" [{'; '.join(scope)}]" if scope else "" + lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}") + return "\n".join(lines) + + +def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: + body = ( + "Notebook entries:\n" + + _render_notes(notes) + + f"\n\nQuestion: {question}\n\n" + "Answer using only these entries, citing note ids." + ) + return [{"role": "user", "content": body}] + + +async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict: + """Force the ask tool and return its validated input dict. Short-circuits + (no API call) when there are no notes to ground on.""" + if not notes: + return { + "answer": "The notebook doesn't cover this yet.", + "points": [], + "suggested_next": [], + "coverage": "not_in_notebook", + } + resp = await client.messages.create( + model=model, + max_tokens=2048, + system=_SYSTEM, + tools=[ASK_TOOL], + tool_choice={"type": "tool", "name": ASK_TOOL["name"]}, + messages=build_ask_messages(question, notes), + ) + for block in resp.content: + if getattr(block, "type", None) == "tool_use": + return block.input + return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"} diff --git a/tests/test_notebook_ask.py b/tests/test_notebook_ask.py new file mode 100644 index 00000000..9f501090 --- /dev/null +++ b/tests/test_notebook_ask.py @@ -0,0 +1,87 @@ +"""Tests for 'Ask the notebook' — retrieval + grounded synthesis.""" + +import asyncio + +from gently.harness.memory.notebook import Note, NoteKind +from gently.harness.memory.notebook_ask import ( + ASK_TOOL, + answer_question, + build_ask_messages, + select_notes, +) + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) + return nb + + +class TestSelectNotes: + def test_scope_by_thread(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, thread="t1")} + assert ids == {"o1", "f1"} + + def test_scope_by_strain(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, strain="OH904")} + assert ids == {"x1"} + + def test_no_scope_returns_recent_capped(self, file_context_store): + nb = _seed(file_context_store) + notes = select_notes(nb, limit=2) + assert len(notes) == 2 # newest-first, capped + + +class _FakeBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _FakeResp: + def __init__(self, inp): + self.content = [_FakeBlock(inp)] + self.stop_reason = "tool_use" + + +class _FakeMessages: + def __init__(self, captured, inp): + self._captured, self._inp = captured, inp + + async def create(self, **kwargs): + self._captured.update(kwargs) + return _FakeResp(self._inp) + + +class _FakeClient: + def __init__(self, inp): + self.captured = {} + self.messages = _FakeMessages(self.captured, inp) + + +class TestAnswerQuestion: + def test_build_messages_embeds_note_ids(self): + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + msgs = build_ask_messages("what formed?", notes) + text = msgs[0]["content"] + assert "o1" in text and "ring formed" in text and "what formed?" in text + + def test_answer_returns_structured_and_forces_tool(self): + canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = _FakeClient(canned) + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + out = asyncio.run(answer_question(client, "m", "what formed?", notes)) + assert out == canned + assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]} + assert client.captured["model"] == "m" + + def test_answer_no_notes_short_circuits_without_api(self): + client = _FakeClient({"should": "not be used"}) + out = asyncio.run(answer_question(client, "m", "anything?", [])) + assert out["coverage"] == "not_in_notebook" + assert client.captured == {} # no API call when nothing to ground on From 13b4dd245c5b540d1cc1dd7d56ae63f854a563c8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:21:20 +0530 Subject: [PATCH 055/200] =?UTF-8?q?feat(notebook):=20POST=20/api/notebook/?= =?UTF-8?q?ask=20=E2=80=94=20grounded=20notebook=20Q&A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/notebook.py | 25 +++++++++++++- tests/test_notebook_api.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index b1585ff8..b9cb27da 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -4,7 +4,7 @@ Read-only here; authoring/curation come in a later increment. """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Body, HTTPException from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict @@ -73,4 +73,27 @@ async def list_threads(): threads = [{"id": t, "count": c} for t, c in sorted(counts.items())] return {"available": True, "threads": threads} + @router.post("/api/notebook/ask") + async def ask( + question: str = Body(..., embed=True), + thread: str | None = Body(None, embed=True), + strain: str | None = Body(None, embed=True), + ): + nb = _nb() + if nb is None: + return {"available": False} + from gently.harness.memory.notebook_ask import answer_question, select_notes + from gently.settings import settings + + notes = select_notes(nb, thread=thread, strain=strain) + client = getattr(server, "claude_async", None) + if client is None: + import anthropic + + client = anthropic.AsyncAnthropic() + result = await answer_question(client, settings.models.main, question, notes) + result["available"] = True + result["note_ids"] = [n.id for n in notes] + return result + return router diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index b24abbd9..1d18333b 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -90,3 +90,59 @@ def test_limit_returns_newest(self, file_context_store): client = TestClient(_make_app(_seed(file_context_store))) data = client.get("/api/notebook/notes?limit=1").json() assert len(data["notes"]) == 1 # newest-first, capped + + +class _AskBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _AskResp: + def __init__(self, inp): + self.content = [_AskBlock(inp)] + self.stop_reason = "tool_use" + + +class _AskMessages: + def __init__(self, inp): + self._inp = inp + + async def create(self, **kwargs): + return _AskResp(self._inp) + + +class _AskClient: + def __init__(self, inp): + self.messages = _AskMessages(inp) + + +def _make_app_with_client(context_store, client): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + server.claude_async = client + app.include_router(create_router(server)) + return app + + +class TestAsk: + def test_ask_returns_grounded_answer(self, file_context_store): + cs = _seed(file_context_store) + canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = TestClient(_make_app_with_client(cs, _AskClient(canned))) + resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) + assert resp.status_code == 200 + assert resp.json()["coverage"] == "covered" + + def test_ask_no_store(self): + client = TestClient(_make_app(None)) + resp = client.post("/api/notebook/ask", json={"question": "x"}) + assert resp.json() == {"available": False} From c7be82a81bd87431c2fa50494d2450fe5e886308 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:39:59 +0530 Subject: [PATCH 056/200] feat(notebook): 'Ask the notebook' box on the Notebook tab Question field -> POST /api/notebook/ask, rendering the grounded answer with a coverage badge, cited 'Why' points (note-id chips), and 'Try next' steps. Asks within the selected thread scope. Verified live against Opus 4.8. --- gently/ui/web/static/css/notebook.css | 42 +++++++++++++++++++++ gently/ui/web/static/js/notebook.js | 54 +++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 6 +++ 3 files changed, 102 insertions(+) diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css index 14810510..fa4cebab 100644 --- a/gently/ui/web/static/css/notebook.css +++ b/gently/ui/web/static/css/notebook.css @@ -19,6 +19,48 @@ .nb-kind:hover { color: var(--text, #0f172a); border-color: var(--border-strong, #cbd5e1); } .nb-kind.active { background: var(--accent, #2f6df6); border-color: var(--accent, #2f6df6); color: #fff; } +/* Ask the notebook */ +.nb-ask { display: flex; gap: 9px; margin-bottom: 14px; } +.nb-ask-input { + flex: 1; border: 1px solid var(--border, #e4e9f0); border-radius: 11px; + padding: 11px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a); + background: var(--bg-card, #fff); +} +.nb-ask-input:focus { outline: none; border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96, 165, 250, .15); } +.nb-ask-go { + flex: none; border: 0; border-radius: 11px; padding: 11px 20px; font: inherit; font-size: 14px; + font-weight: 600; color: #fff; background: var(--accent, #2f6df6); cursor: pointer; transition: background .15s; +} +.nb-ask-go:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } + +.nb-ask-result { + border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 16px 18px; margin-bottom: 18px; + background: var(--accent-soft, #f5f8ff); +} +.nb-ask-result[hidden] { display: none; } +.nb-ask-loading, .nb-ask-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; } +.nb-ask-cov { + display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + padding: 2px 9px; border-radius: 999px; margin-bottom: 10px; color: #fff; background: var(--text-muted, #94a3b8); +} +.nb-cov-covered { background: var(--accent-green, #16a34a); } +.nb-cov-partial { background: #d97706; } +.nb-cov-not_in_notebook { background: var(--text-muted, #94a3b8); } +.nb-ask-answer { font-size: 14.5px; line-height: 1.55; color: var(--text, #0f172a); } +.nb-ask-h { + font-size: var(--v2-fs-eyebrow, 11px); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + color: var(--text-secondary, #475569); margin: 14px 0 6px; +} +.nb-ask-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 7px; } +.nb-ask-point { font-size: 13.5px; line-height: 1.45; color: var(--text, #0f172a); display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } +.nb-cite { + font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--accent, #2f6df6); background: var(--bg-card, #fff); + border: 1px solid var(--border, #e4e9f0); border-radius: 6px; padding: 1px 6px; +} +.nb-ask-next { margin: 0; padding-left: 18px; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #475569); } +.nb-ask-next li { margin: 2px 0; } + .nb-body-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: start; } @media (max-width: 760px) { .nb-body-wrap { grid-template-columns: 1fr; } } diff --git a/gently/ui/web/static/js/notebook.js b/gently/ui/web/static/js/notebook.js index 2fe94c6c..1130a31a 100644 --- a/gently/ui/web/static/js/notebook.js +++ b/gently/ui/web/static/js/notebook.js @@ -123,12 +123,66 @@ const NotebookApp = (() => { }); } + // ── Ask the notebook ─────────────────────────────────────────────── + function renderAskResult(data) { + const box = $('nb-ask-result'); + if (!box) return; + box.hidden = false; + if (!data || data.available === false) { + box.innerHTML = '
The notebook is unavailable right now.
'; + return; + } + const cov = data.coverage || 'covered'; + const covLabel = { covered: 'Grounded in the notebook', partial: 'Partially covered', not_in_notebook: 'Not in the notebook yet' }[cov] || cov; + const points = (data.points || []).map(p => ` +
  • + ${esc(p.text)} + ${(p.note_ids || []).map(id => `${esc(id)}`).join('')} +
  • `).join(''); + const nexts = (data.suggested_next || []).map(s => `
  • ${esc(s)}
  • `).join(''); + box.innerHTML = + `
    ${esc(covLabel)}
    ` + + `
    ${esc(data.answer || '')}
    ` + + (points ? `
    Why
      ${points}
    ` : '') + + (nexts ? `
    Try next
      ${nexts}
    ` : ''); + } + + async function ask() { + const input = $('nb-ask-input'); + const box = $('nb-ask-result'); + const q = (input && input.value || '').trim(); + if (!q) return; + if (box) { box.hidden = false; box.innerHTML = '
    Thinking over the notebook…
    '; } + const body = { question: q }; + if (threadFilter) body.thread = threadFilter; // ask within the selected thread + try { + const r = await fetch('/api/notebook/ask', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + renderAskResult(await r.json()); + } catch (e) { + if (box) box.innerHTML = '
    Something went wrong asking the notebook.
    '; + } + } + + function setupAsk() { + const go = $('nb-ask-go'), input = $('nb-ask-input'); + if (go) go.addEventListener('click', ask); + if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }); + } + + function esc(s) { + return (typeof escapeHtml === 'function') ? escapeHtml(String(s == null ? '' : s)) + : String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + } + function refresh() { loadThreads(); loadNotes(); } function init() { if (inited) { refresh(); return; } inited = true; setupFilters(); + setupAsk(); refresh(); // Notebook writes ride the CONTEXT_UPDATED event — live-refresh if visible. if (typeof ClientEventBus !== 'undefined') { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 242e5441..8bc8bf06 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -423,6 +423,12 @@

    Notebook

    +
    + + +
    +
    From ecd52390da8deb4cf1dbc234feec3661cec6c971 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 02:48:48 +0530 Subject: [PATCH 057/200] feat(imaging): Run button on actionable imaging plan items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a context-placed '▶ Run this imaging item' button to the plan-item inspector (imaging + planned only). Routes through the agent (AgentChat.runCommand) to execute_plan_item, which applies that item's ImagingSpec and starts the timelapse — agent-first, so it stays in the loop and can confirm/adjust. First slice of the multi-entry-point imaging triggers (workspace 'embryos mounted' CTA + resolution-picker continuation to follow). --- gently/ui/web/static/js/campaigns.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index 36d29623..da1bd515 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -117,6 +117,7 @@ function boot() { case 'select-item': selectItem(id); break; case 'open-campaign': openCampaign(id); break; case 'navigate-item': e.stopPropagation(); navigateToItem(id); break; + case 'run-item': e.stopPropagation(); runPlanItem(id); break; case 'filter-type': applyTypeFilter(el.dataset.filterType); break; case 'view-version': viewVersion(el.dataset.versionId, el.dataset.isCurrent === 'true'); break; case 'back-to-current': backToCurrent(); break; @@ -639,6 +640,19 @@ function renderInspector(data) { ${item.id}
    `; + // Run affordance — only for an actionable imaging item. Routes through the + // agent (it applies this item's spec via execute_plan_item), in keeping with + // the agent-first paradigm. + if (item.type === 'imaging' && item.status === 'planned') { + html += `
    + + Hands it to the agent to apply the spec and start +
    `; + } + // Description if (item.description) { html += section('Description', `
    ${esc(item.description)}
    `); @@ -727,6 +741,15 @@ function renderInspector(data) { if ($inspectorBody) $inspectorBody.innerHTML = html; } +// Hand a planned imaging item to the agent to execute. The agent resolves the +// item ref, applies its spec, and starts the timelapse (execute_plan_item). We +// open the chat so the user sees it pick up and can confirm/adjust. +function runPlanItem(id) { + if (typeof AgentChat === 'undefined' || !AgentChat.runCommand) return; + AgentChat.runCommand(`Start imaging for plan item ${id}`); + if (AgentChat.togglePanel) AgentChat.togglePanel(true); +} + function closeInspector() { state.selectedItemId = null; $workspace?.classList.remove('inspector-open'); From 6eac3e6ded9f8aa23ac123e7041dc4ab83fb13a3 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 03:05:58 +0530 Subject: [PATCH 058/200] deps: declare opencv-python-headless (cv2) as a runtime dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cv2 is imported unguarded by sam_detection, the device layer, analysis/steps, and video_maker, but was never declared — so a clean install breaks detection/ analysis with 'No module named cv2'. headless avoids the libGL.so requirement of full opencv-python on headless servers/agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d2a0c9a4..6f2408cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,11 @@ dependencies = [ "tifffile>=2023.0.0", "scipy>=1.10.0", "scikit-image>=0.21.0", + # OpenCV is imported (unguarded) by detection (sam_detection), the device + # layer, analysis steps, and video_maker — so it's a true runtime dep, not + # optional. headless avoids the libGL.so requirement of full opencv-python + # on headless servers/agents. + "opencv-python-headless>=4.8.0", "jinja2>=3.1.0", "pyyaml>=6.0", "matplotlib>=3.7.0", From 0d01229ef604e3bc1d87c335c005f5a5145a7a86 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Tue, 16 Jun 2026 18:42:16 -0400 Subject: [PATCH 059/200] fix(dispim): correct stage-position read keys; bottom camera never uses LED Two fixes from live testing on integration/ux2-all: 1. get_stage_position() in the client parsed the read_stage_plan event with a stale key list (["XY:31", "xy_stage", "stage"]) that never matched the device's actual key ("XYStage:XY:31"/"xy_stage_position"), so every read raised "Failed to read stage position" (also broke view_image, which reads the stage). Align the key list with the one the device layer's own handle_detect_embryos already uses. 2. The bottom camera no longer drives the LED at all. trigger() ignores the persistent use_led flag and captures under room light only, so no caller (manual marking, detection, live preview) can flash the LED. capture_for_marking no longer requests use_led=True, and handle_detect_embryos turns the room light on before capturing. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/hardware/dispim/client.py | 17 ++++++++++-- gently/hardware/dispim/device_layer.py | 35 ++++++++++++++++++++++++ gently/hardware/dispim/devices/camera.py | 33 ++++++---------------- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 2e4567e1..56a9a33c 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -374,8 +374,18 @@ async def get_stage_position(self) -> tuple[float, float]: events = docs.get("events", []) if events: data = events[0].get("data", {}) - # Look for stage coordinates - for key in ["XY:31", "xy_stage", "stage"]: + # Look for stage coordinates. Keys must match what + # read_stage_plan (bp.count on the xy_stage device) actually + # emits — the device-layer's own handle_detect_embryos uses + # "XYStage:XY:31"/"xy_stage_position", so include those here too + # (the bare "XY:31" was stale and never matched). + for key in [ + "xy_stage", + "XYStage:XY:31", + "xy_stage_position", + "XY:31", + "stage", + ]: if key in data: val = data[key] if isinstance(val, (list, tuple)) and len(val) >= 2: @@ -1244,7 +1254,8 @@ async def capture_for_marking( self._ensure_connected() try: - snap = await self.capture_bottom_image(use_led=True, exposure_ms=exposure_ms) + # No LED ever — the bottom camera images under room light only. + snap = await self.capture_bottom_image(use_led=False, exposure_ms=exposure_ms) image = snap["image"] if image is None or (image.shape == (100, 100) and image.max() == 0): diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 6d606621..55e8225d 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -1428,6 +1428,26 @@ def _room_light_device(self): return dev return None + async def _set_room_light(self, state: str) -> bool: + """Drive the room-light SwitchBot to ``state`` ('on'/'off'/'press'). + + Blocks until the BLE command lands (or times out). Returns True on + success, False if no bot is configured or the command failed. Shared + by the HTTP handler and internal callers (e.g. detect_embryos, which + images under room light rather than the camera LED). + """ + bot = self._room_light_device() + if bot is None: + return False + import time + + status = bot.set(state) + timeout = float(getattr(bot, "timeout", 20.0)) + 5 + start = time.time() + while not status.done and (time.time() - start) < timeout: + await asyncio.sleep(0.1) + return bool(status.done and status.success) + async def handle_get_room_light_status(self, request): """GET /api/room_light/status - cached on/off state of the room light. @@ -1931,6 +1951,21 @@ async def handle_detect_embryos(self, request): if bottom_camera: bottom_camera.configure_exposure(exposure_ms) + # Detection always images under ROOM LIGHT, never the camera LED. + # use_led is a persistent device flag that other flows (e.g. manual + # marking via capture_for_marking) leave switched on, so force it + # off here and turn the room light on so there's illumination. + bottom_camera = self.devices.get("bottom_camera") + if bottom_camera is not None: + bottom_camera.use_led = False + if await self._set_room_light("on"): + logger.info("[detect_embryos] Room light ON, camera LED disabled") + else: + logger.warning( + "[detect_embryos] Could not turn room light on " + "(no SwitchBot configured?) — capturing under ambient light" + ) + # Capture image via plan logger.info("[detect_embryos] Capturing bottom camera image...") capture_result = await self.submit_plan( diff --git a/gently/hardware/dispim/devices/camera.py b/gently/hardware/dispim/devices/camera.py index 5141d6d4..505dcf3b 100644 --- a/gently/hardware/dispim/devices/camera.py +++ b/gently/hardware/dispim/devices/camera.py @@ -388,7 +388,9 @@ def __init__( self.pixel_size_um = pixel_size_um self.magnification = magnification self.effective_pixel_size = pixel_size_um / magnification - self.use_led = False # Set to True to enable automatic LED control + # Retained for API compatibility but ignored: the bottom camera never + # drives the LED (see trigger()). Imaging uses room light only. + self.use_led = False def pixel_to_um(self, pixels: float) -> float: """ @@ -408,14 +410,12 @@ def pixel_to_um(self, pixels: float) -> float: def trigger(self): """ - Trigger image acquisition with optional LED management. + Trigger image acquisition. - Overrides parent trigger() to add LED control (if use_led=True): - 1. Turn LED on (if use_led=True) - 2. Capture image - 3. Turn LED off (if use_led=True, always even on error) - - Set self.use_led = False to capture without LED (ambient light only). + The bottom camera NEVER drives the LED — imaging is done under room + light only. The ``use_led`` flag is retained for API compatibility but + is intentionally ignored here so that no caller (manual marking, + detection, live preview, …) can ever flash the LED. Returns ------- @@ -426,28 +426,13 @@ def trigger(self): def wait(): try: - # Turn LED on for transmitted light imaging (if enabled) - if self.use_led: - self.led_control.set("Open").wait(timeout=5) - time.sleep(0.1) # Allow LED to stabilize - - # Capture image + # LED is never used — capture under ambient/room light only. self._ensure_active() self.core.snapImage() self._last_image = _safe_obtain(self.core.getImage()) self._last_image_time = time.time() - # Turn LED off (if enabled - important to prevent sample heating!) - if self.use_led: - self.led_control.set("Closed").wait(timeout=5) - except Exception as exc: - # Critical: always turn off LED even on error (if enabled) - if self.use_led: - try: - self.led_control.set("Closed").wait(timeout=5) - except Exception: - pass status.set_exception(exc) else: status.set_finished() From 9abcf6e608a1c03a943263cde75e7ce236930787 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Tue, 16 Jun 2026 18:42:17 -0400 Subject: [PATCH 060/200] chore(deps): lock opencv-python-headless (uv.lock sync) Regenerate uv.lock for the opencv-python-headless runtime dep declared in pyproject (6eac3e6d). Co-Authored-By: Claude Opus 4.8 (1M context) --- uv.lock | 558 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 457 insertions(+), 101 deletions(-) diff --git a/uv.lock b/uv.lock index 60a87123..56e9f456 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] conflicts = [[ { package = "gently", extra = "torch-cpu" }, @@ -159,6 +159,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -242,6 +266,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -268,10 +301,9 @@ name = "contourpy" version = "1.3.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -321,11 +353,12 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -399,6 +432,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/f8/1070ab5973dda406f735f6f73a4bc50debf7c43301f5e113fd3657bfca58/dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8b2c92f82af04d59c50cbccf65f995fa2e321ce7850e3c1b613e94680a8044e", size = 860033, upload-time = "2026-05-27T19:27:55.167Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -605,6 +647,7 @@ dependencies = [ { name = "jinja2" }, { name = "matplotlib" }, { name = "numpy" }, + { name = "opencv-python-headless" }, { name = "ophyd" }, { name = "pillow" }, { name = "pymmcore" }, @@ -642,8 +685,11 @@ torch-gpu = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] @@ -658,6 +704,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.7.0" }, { name = "numpy", specifier = ">=1.24.0,<2" }, { name = "nvidia-ml-py", marker = "extra == 'torch-gpu'", specifier = ">=12.0.0" }, + { name = "opencv-python-headless", specifier = ">=4.8.0" }, { name = "ophyd", specifier = ">=1.9.0" }, { name = "paho-mqtt", marker = "extra == 'device'", specifier = ">=1.6.0" }, { name = "pillow", specifier = ">=10.0.0" }, @@ -679,8 +726,11 @@ provides-extras = ["sam", "torch-gpu", "torch-cpu", "device"] [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = "==2.1.0" }, + { name = "pre-commit", specifier = ">=3.7.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "ruff", specifier = ">=0.4.0" }, ] [[package]] @@ -689,20 +739,62 @@ version = "0.1.0" source = { editable = "../gently-perception" } dependencies = [ { name = "anthropic" }, + { name = "moderngl" }, { name = "numpy" }, { name = "pillow" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40.0" }, { name = "matplotlib", marker = "extra == 'benchmark'", specifier = ">=3.7.0" }, + { name = "moderngl", specifier = ">=5.10" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pillow", specifier = ">=10.0.0" }, + { name = "scipy", specifier = ">=1.10" }, { name = "tifffile", marker = "extra == 'benchmark'", specifier = ">=2024.1.30" }, ] provides-extras = ["benchmark"] +[[package]] +name = "glcontext" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/80/8238a0e6e972292061176141c1028b5e670aa8c94cf4c2f819bd730d314e/glcontext-3.0.0.tar.gz", hash = "sha256:57168edcd38df2fc0d70c318edf6f7e59091fba1cd3dadb289d0aa50449211ef", size = 16422, upload-time = "2024-08-10T20:01:20.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/44/f605689047cca15d5e5d31c0a4b9ca87dd1d52bfabf3c3a05d70a4636c08/glcontext-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b154c25a57e16dbb073478d0cbe2c0090649d135c4c9f87e753b6181b97ec848", size = 9329, upload-time = "2024-08-10T19:59:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/fd/461c9e37ad50514dac82fda04a7a0aabb1a5e6b0f76476538caa4e74a25a/glcontext-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa5a14778d13ecf4a0dd60a7825427bf6ac0383eacb3b8b1a9c23e4976aa0c8c", size = 9735, upload-time = "2024-08-10T19:59:51.329Z" }, + { url = "https://files.pythonhosted.org/packages/db/86/c879bf61d4f52d8b0cd702db5f7180b5eae8804d29a798de8da0568a64b2/glcontext-3.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c229290a3a33004a59799b94a50bc5e6f8addd1b5bc5039ef9e78f86d888b31", size = 50476, upload-time = "2024-08-10T19:59:52.854Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c4/8b45b1c910f4a9b4df1ee3449b4c1aaa798fc25d86390e8eb2a18bc59669/glcontext-3.0.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1445a03d8795113034e1f9ffa662f795df65ae69ee21b26ed3b1d66100ba3f8c", size = 51480, upload-time = "2024-08-10T19:59:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/159f7df65554f30e0cc6f7fb78869e95717ed5319729f386dcc81ae9ee3d/glcontext-3.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09247011c09c37b8d30eca9aa24659288de2febaeaa6a817b33b1498b5ef164c", size = 44902, upload-time = "2024-08-10T19:59:55.268Z" }, + { url = "https://files.pythonhosted.org/packages/91/85/21d2b619576690d48e4967dce37fabe616aeee06b7c6a6c6ceb48d99d3ef/glcontext-3.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8c1223f1cbcfc0b88428e1717baca829ee863ed5d88e9b5574c7ed6598249cd", size = 47035, upload-time = "2024-08-10T20:00:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/5e/99/6c200c6d16b55d19dfd13bfed1ed5794e17e49ea8ccf9e4fab52e2d0e2d0/glcontext-3.0.0-cp310-cp310-win32.whl", hash = "sha256:c13dedb3636328b133c4d53c047ce69040ae784095e8f239432ad74d6f921712", size = 12218, upload-time = "2024-08-10T20:00:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/93/36/8a8f25366ab80dc4e6c9ded7b21f11152bba65fd6b8c0f28641ef3e133f5/glcontext-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4817f4cd52c7fe5410c92ca12b6712435548918719373882ade76f8f75d80abd", size = 12973, upload-time = "2024-08-10T20:00:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/01/36/3d0d09f7352b179a7ecc5fc3322beb85c8995c66db780acf791853e49043/glcontext-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a9e56fa3597cc709cfd0fdf2ae682cda36510a13faac2b3142f401e823b64f4", size = 9328, upload-time = "2024-08-10T20:00:09.43Z" }, + { url = "https://files.pythonhosted.org/packages/44/9d/0c8fd9c660db000071ebace14a40bd381a41775c10faa262fedfae8227e3/glcontext-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a0484308af75e04b0e56066dc2324a8fb9f1443b76ddb98833439982322b2a39", size = 9738, upload-time = "2024-08-10T20:00:10.831Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/7bcadb995830cdd6a1a31f0527a52b2c441a499feabed9749106f7e41e67/glcontext-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983231394396aa2a1e2b96df49404cc8f8aa729d462ed40e605a74b079c46342", size = 50663, upload-time = "2024-08-10T20:00:12.301Z" }, + { url = "https://files.pythonhosted.org/packages/43/fb/646c2773cb097b914afe1f06c95e65deb8a544d770389bf29c76a8f3a8fd/glcontext-3.0.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa413f4420abff2bbb5aa5770a3e1deffcdc13e0ef2f459b145fa79c36909e7", size = 51680, upload-time = "2024-08-10T20:00:13.646Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/04aac6c50071b858cfd02a9bccafb42a21f567992fa448c8ad8aa62939b9/glcontext-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7d0ac35ac07fc91eccea093beb9d1c1a4eae250bc33836047deff01a3b5f4757", size = 45063, upload-time = "2024-08-10T20:00:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6e/6e398492b55f3c453cc6a2ecc2886a01b2a465623aa0c476d3e115be85c4/glcontext-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7145d17a70adc5784ca59ebbe19a56435ba21816070b8b433f43aa2dfb8be71a", size = 47281, upload-time = "2024-08-10T20:00:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fd/4f59118e5067a3c88217862d8672463aff29f29c1ea9f47971f8ca67e83c/glcontext-3.0.0-cp311-cp311-win32.whl", hash = "sha256:b31808ca2517fedcac8ca5b296ff46c8af012911eaa2080889a1f244d329ef9a", size = 12222, upload-time = "2024-08-10T20:00:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/20/1b/f402574ae4644e7fb389b9534de9d1be575b8a4da901a9023d1cec72c4aa/glcontext-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef4b4ec35e2b720f4cd250bb92cf6417add445490bf780345596da5a796a0e6f", size = 12975, upload-time = "2024-08-10T20:00:19.236Z" }, + { url = "https://files.pythonhosted.org/packages/6e/61/d8f77d44bbf477b235ecbff8d421a5511d4b4f6dc676dacd84d012348516/glcontext-3.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:848f870a2bc72a29de7ab6756b9e8f2e6ce052e17873ebc6b3f25129b6e0d58a", size = 9360, upload-time = "2024-08-10T20:00:20.42Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/680a97d974cfe7af798542918b65bd8e65a5f8f7647edfd9fdb91a95df6c/glcontext-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3b12a66f57379566dd4d36899ac265abdbe040f3fc3293f50cd6678a1dcc9b", size = 9733, upload-time = "2024-08-10T20:00:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/74/2c/be188c4eb63b4d0cc74c644a1519b5e3a37488da3cbda724570cd5fed8d3/glcontext-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:449eaefd89c0519900715b8363ead59ac4aa32457722ca521ce01297441edb34", size = 50409, upload-time = "2024-08-10T20:00:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/32/ba/9ccb80650e5bd61e739f16f33aec3bb290a80f314631be8f7dc0a2b22b5f/glcontext-3.0.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04921720740438ceea8fb8a38b5665963520c7c8f27bef03df8aeb3ea3cfbfb6", size = 51440, upload-time = "2024-08-10T20:00:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/c0/99/f6c9a0e614809ba5b83bd8403c475b788bd574d694bd5bc6b6ae2e2cefdf/glcontext-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:25538bdb106f673638d70e8a16a0c037a92a24c4cf40a05f0d3fa14b483d6194", size = 44820, upload-time = "2024-08-10T20:00:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/a2/29/fdbb94e4c9374390639b741774384f7413efcd7634cc9c4baacc2cf00a1f/glcontext-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d11f7701b900a5a34c994e1d91c547be1cc469b73f881471460fd905f69f9e4c", size = 47055, upload-time = "2024-08-10T20:00:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fe/25b5348fe5e856697dacad34fc07e80f48eecfb38bd09806679fe0e62769/glcontext-3.0.0-cp312-cp312-win32.whl", hash = "sha256:5d2b567eaf34adb016aadce81fd2f1d4c8e4a39e3d6f2a395ce528e2a350dd3f", size = 12219, upload-time = "2024-08-10T20:00:27.76Z" }, + { url = "https://files.pythonhosted.org/packages/17/d3/6619693ddad97011ca1c9aaeb82216ab2bfd54757be752b12f4e9a2fc489/glcontext-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e80bb37ba727bd20c192f2754aea40c437a7665005c1001c10752f91913964e9", size = 12971, upload-time = "2024-08-10T20:00:29.575Z" }, + { url = "https://files.pythonhosted.org/packages/ee/84/8f6ca5b8005ec58adbf51e2e6c3b5a20f234ea8f7a2e1df52835c9b23274/glcontext-3.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f4e285e5d40e7a9bafeb0651c3d8f1a7c822525dec7091e97109ba87a70dbd1e", size = 9272, upload-time = "2024-08-10T20:01:00.133Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/a0d1e7492f14727682820120062717a118f0196cda8cf6c1a6e6c4ab6d12/glcontext-3.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:96d1bbe62c5bc5315ca2f84a2abae6fa7b7d645dd368415a0cd1ee5ba6f3f8f7", size = 9581, upload-time = "2024-08-10T20:01:01.098Z" }, + { url = "https://files.pythonhosted.org/packages/07/ba/2f093542218c9c38b293df0f501509fdfeb5dad975135a5467431aa9a774/glcontext-3.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a190b1cdb39110c7b56c33857dbff493a364633bfd0ff402a1ce205956c94ca", size = 17842, upload-time = "2024-08-10T20:01:02.308Z" }, + { url = "https://files.pythonhosted.org/packages/01/e5/5209be64858f9d7f9861f03ed5662491642d8d31b51a6a8495f084869386/glcontext-3.0.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d467cce2dac8c3928847e90310eb6bdfdfa69f8df39b76a74734046faa15e688", size = 17226, upload-time = "2024-08-10T20:01:03.362Z" }, + { url = "https://files.pythonhosted.org/packages/06/cb/0995eef99e56394b93bea3aa20bc269ae09125b66c0b7c1e3338f44c93cb/glcontext-3.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2b0c5240125d75498a8f14596484233e4effe98a6a035f566872dd2fdf472ceb", size = 12988, upload-time = "2024-08-10T20:01:04.728Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -778,6 +870,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.17" @@ -992,6 +1093,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1079,6 +1226,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] +[[package]] +name = "moderngl" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "glcontext" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/52/540e2f8c45060bb2709f56eb5a44ae828dfcc97ccecb342c1a7deb467889/moderngl-5.12.0.tar.gz", hash = "sha256:52936a98ccb2f2e1d6e3cb18528b2919f6831e7e3f924e788b5873badce5129b", size = 193232, upload-time = "2024-10-17T12:36:28.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/4b/988c5f4ef55bf0c62a0b63a4b32fe6d6dd755d12f5951e8fd56e8a30d3f6/moderngl-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:105cdee2ee29a7e6ebbc76cf178941503656a185c6945933bc7ef395ba8e65a7", size = 111803, upload-time = "2024-10-17T12:36:34.271Z" }, + { url = "https://files.pythonhosted.org/packages/97/40/272d38d9d096d591db08e4aa1c983a8ed34cd1717b6524a88539b0df4341/moderngl-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:18deb8bebd0a4277d92c76dbedf8e4b4b68bf0a8a878404c6b26aed750890d3b", size = 109223, upload-time = "2024-10-17T12:36:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5c/a6f2743c8ae1c95cd6531510bf76fa6be650a5e0a1dffdf138b5ed186f61/moderngl-5.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f0c4f7c42425177168938386a4fabd734ca3bbb5de2d1fd1176cfa6f980fc13", size = 291432, upload-time = "2024-10-17T12:36:37.122Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2d/b6bfc4fe7df343a4f410030dbd8261566282049e56e9ffc3cbd2e626656e/moderngl-5.12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:878cdf593204d85c020305f21d306f979353a67c932b9df58ea936f6e5ad13e7", size = 265422, upload-time = "2024-10-17T12:36:38.767Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ae/35f8f4c833aca90870110509dddad641f5adf9597da15b3c9e7c75db319f/moderngl-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:00d94f9cb485d87c85088edad624201e152d8ac401793a024b16cd9e2bc4dbf6", size = 1342266, upload-time = "2024-10-17T12:36:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4b/2e0b6d0e8a5273da93ed82c8eb91b54a37413b1b9d65bb223d67b595fc96/moderngl-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:edd91057b8d76beebac0d7b0c741466ee8f37eaf3c07856785c2872afe0b35ac", size = 1270055, upload-time = "2024-10-17T12:36:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/b590f86d42630fbbaffd83f1890ef6278ec12d5cb336b6a2efcbb3c2ca21/moderngl-5.12.0-cp310-cp310-win32.whl", hash = "sha256:3d066eae2eb44e81bd7addf565adebc041bdee119e7ac6e4f95831d6f327a938", size = 101044, upload-time = "2024-10-17T12:36:43.94Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/f9b77797d3a0f3e5b67a75092c6c63b26360042fd8e43be017c706e308a8/moderngl-5.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:76d966194d51852f48c42a6e786a4520f1e1be5f93e2626423d673663422d559", size = 108272, upload-time = "2024-10-17T12:36:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ea/569c2c08bfef84f4acf633a8e6d956f4f75cfaa8832d7d812dbf2ff6843a/moderngl-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28cdba5dcf2d03c89bb25dc3b2f5770ac4104470ed5bbe680a15494fa52a537d", size = 111802, upload-time = "2024-10-17T12:36:47.377Z" }, + { url = "https://files.pythonhosted.org/packages/00/ef/98f36133ab010ce9831b75a16e75a627c12a4c1d6ef2e353eca1769a1e09/moderngl-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dad93893e3fcb2410bfd31e854f20e1370b4fbafa07a737f1046f5fbd29ba0f4", size = 109227, upload-time = "2024-10-17T12:36:49.24Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/ab371acacd2497bddb5f02b209e3bfae452b2be59d0cf8fa728a3b87de1f/moderngl-5.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc0f8788bc84433d2124e9a4893adbe40f93c7d213abb8ad7b909540cb0161f", size = 293468, upload-time = "2024-10-17T12:36:50.8Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/7ebf2b98da310c90c2b295e91b6c25f864f0f5583ce86bee72d387cb577a/moderngl-5.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6efd3fe0d2c9652af21e2c1f5a936a2b971abac5bdd777da7182a54962466cab", size = 267348, upload-time = "2024-10-17T12:36:52.526Z" }, + { url = "https://files.pythonhosted.org/packages/61/0a/87fb24f4cd2aa07150b84fbf400edf4d8a8f71784cbf064f1ed92b756fea/moderngl-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6f3bd2d534fc081cde30545b84ebca63aef847ba8bd533217b9a37f565614ade", size = 1343953, upload-time = "2024-10-17T12:36:54.601Z" }, + { url = "https://files.pythonhosted.org/packages/83/42/11b0306e630d9a38b8bac20563b326f6d5fba4dfc45cc90b0666ed3e7141/moderngl-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eaa3de9446c6febec4d5f888e6f1a4e9398bc5a5ea70b1570ea447213641d4a6", size = 1271832, upload-time = "2024-10-17T12:36:56.383Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/74d300275fe4834e63b8d70450801f206d005f2047898d4eb2a30efc3913/moderngl-5.12.0-cp311-cp311-win32.whl", hash = "sha256:9fdb76f1fd890db67727c8cdee4db2ee6319068c7ce92be0308366f8745e28ab", size = 101043, upload-time = "2024-10-17T12:36:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/7f/08/5f615a4605d343cd5c1112d2b175270e6a5586008bc10a85b822c340cf86/moderngl-5.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:0c210e8d52a60025f6586ca015c39feb1e57e6dc792c3ff44800f6493a541b1a", size = 108279, upload-time = "2024-10-17T12:37:01.125Z" }, + { url = "https://files.pythonhosted.org/packages/3c/66/31161e81bc85ca3cdbb9d94f703f21575e4ae9a2919e9d1af98fc7fdb1ba/moderngl-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2750547707c1ec3790dfbeb9c90fb808672ff13f61cac392c706ba09fda10db0", size = 112101, upload-time = "2024-10-17T12:37:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/84/b2/7229a89a40d33a95119a7c64c7ee36a6a6e376c57c39fb577ea513602f37/moderngl-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c2a5fe06c7021183d9274df798f25516409c8d55898c324dae8a0b2de10144", size = 109377, upload-time = "2024-10-17T12:37:03.843Z" }, + { url = "https://files.pythonhosted.org/packages/d7/96/bcb5141eae24474d80b8157b0c3055d25fa75f9804d4abb4a514695bbba9/moderngl-5.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6c4972f3ddd10a3de6c30311da2c25bc493d023796e16c5d4e0f8bd6d5770be", size = 296394, upload-time = "2024-10-17T12:37:04.967Z" }, + { url = "https://files.pythonhosted.org/packages/b8/79/a9998ddf6757f4f15888b0a106d80a64a8c8991a8ce5c14047830704b9e6/moderngl-5.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d497ec6a3f6afa9ebd0be816d9bfe2fe20fec2105acfb88d956619c3ed8eb4", size = 270548, upload-time = "2024-10-17T12:37:07.57Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/313dc301db936b231035b961e004f1914c2954bdcdf4985e24bff15e7ed5/moderngl-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2f3d240e9bc5d83257378bae59f8f35638b89d22bb003cf674b88fd7932161ce", size = 1345817, upload-time = "2024-10-17T12:37:09.105Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6f/7b3587e7e3ae633b8c85038f035dfeb348ebf805de4beb01241c59c6b97c/moderngl-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6fa667d560d842e778e2a5968305fb78f9781616a11b1b93acd2562f97262ccf", size = 1274968, upload-time = "2024-10-17T12:37:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/0f3b3cd1be7b0a93f33dc613f76a42021d1393b4949c5b6a1ca2a01c6772/moderngl-5.12.0-cp312-cp312-win32.whl", hash = "sha256:0a02fddd54dccee1ca6060bfed75a2e6a17dd3ee06920fac418506d8a8233849", size = 101221, upload-time = "2024-10-17T12:37:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/56/85/35498b1821cf31c731b1882168db8924207ff3c06d8f0da53e1cc373a89d/moderngl-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8698a59ad03539a2982125b7998efc1c107ba31d5d03437b6fcd72cb2c226922", size = 108525, upload-time = "2024-10-17T12:37:13.816Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1201,15 +1383,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "networkx" version = "3.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } wheels = [ @@ -1221,17 +1449,27 @@ name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "1.26.4" @@ -1313,7 +1551,7 @@ name = "nvidia-cudnn-cu11" version = "9.1.0.70" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu11" }, + { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/00/3b/0b776f04e364cd99e4cf152c2a9eadb5934c67c9a91429da55169a9447fd/nvidia_cudnn_cu11-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e6135ac63fe9d5b0b89cfb35c3fc1c1349f2b995becadf2e9dc21bca89d9633d", size = 663919573, upload-time = "2024-04-22T15:20:24.839Z" }, @@ -1347,7 +1585,7 @@ name = "nvidia-cusolver-cu11" version = "11.4.1.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu11" }, + { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/55/ee/939ff0104991dd7bdabb4c9767994c612ba0e1c9a55672a1ddd42f5e5b16/nvidia_cusolver_cu11-11.4.1.48-py3-none-manylinux1_x86_64.whl", hash = "sha256:ca538f545645b7e6629140786d3127fe067b3d5a085bd794cde5bfe877c8926f", size = 128240842, upload-time = "2022-10-03T23:30:24.348Z" }, @@ -1395,6 +1633,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/3f/0e1dd2bc4d89f838b86c76956ffa514307d3be4d8b5ee0da4e9d12a8b54b/nvidia_nvtx_cu11-11.8.86-py3-none-win_amd64.whl", hash = "sha256:54031010ee38d774b2991004d88f90bbd7bbc1458a96bbc4b42662756508c252", size = 66297, upload-time = "2022-10-03T23:39:12.132Z" }, ] +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.42.1" @@ -1443,6 +1698,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -1496,10 +1760,9 @@ name = "pint" version = "0.24.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "flexcache", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -1517,11 +1780,12 @@ name = "pint" version = "0.25.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "flexcache", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -1552,6 +1816,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -1741,7 +2021,7 @@ name = "pyobjc-framework-cocoa" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/cc/927169225e72bab9c9b44285656768fb75052a0bc85fdbca62740e1ca43c/pyobjc_framework_cocoa-12.2.tar.gz", hash = "sha256:20b392e2b7241caad0538dfde12143343e5dfe48f72e7df660a7548e635903dc", size = 3125555, upload-time = "2026-05-30T12:35:09.273Z" } wheels = [ @@ -1755,8 +2035,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/df/bee7ba216f9fb513710aac1701b78c97b087b37fca8ec1806f8572e0bbb3/pyobjc_framework_corebluetooth-12.2.tar.gz", hash = "sha256:8b4e5ca99953c360c391a695b0782a5328fcecafd56fdf790ad709e932feb306", size = 37552, upload-time = "2026-05-30T12:35:33.863Z" } wheels = [ @@ -1770,8 +2050,8 @@ name = "pyobjc-framework-libdispatch" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/fe/e23be301e46c30450955cdb096f16f6a86e7609787a4b8225ec24d6fdc9d/pyobjc_framework_libdispatch-12.2.tar.gz", hash = "sha256:4a41879ef7716b73d70f2e40ff39353d686cbc59d48c93217ed362d2b2baf1ba", size = 40345, upload-time = "2026-05-30T12:39:09.474Z" } wheels = [ @@ -1842,6 +2122,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1907,10 +2200,9 @@ name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ @@ -1977,11 +2269,12 @@ name = "rpds-py" version = "2026.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } wheels = [ @@ -2029,15 +2322,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, ] +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + [[package]] name = "scikit-image" version = "0.25.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "imageio", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2073,11 +2390,12 @@ name = "scikit-image" version = "0.26.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "imageio", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2114,10 +2432,9 @@ name = "scipy" version = "1.15.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2158,11 +2475,12 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2257,10 +2575,9 @@ name = "tifffile" version = "2025.5.10" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2275,11 +2592,12 @@ name = "tifffile" version = "2026.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2330,9 +2648,15 @@ name = "torch" version = "2.7.1+cu118" source = { registry = "https://download.pytorch.org/whl/cu118" } resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "filelock" }, @@ -2370,7 +2694,8 @@ name = "torch" version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ @@ -2394,8 +2719,12 @@ name = "torch" version = "2.12.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform != 'darwin'", - "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "filelock", marker = "sys_platform != 'darwin'" }, @@ -2429,9 +2758,15 @@ name = "torchvision" version = "0.22.1+cu118" source = { registry = "https://download.pytorch.org/whl/cu118" } resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy" }, @@ -2452,7 +2787,8 @@ name = "torchvision" version = "0.27.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ @@ -2471,8 +2807,12 @@ name = "torchvision" version = "0.27.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform != 'darwin'", - "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "sys_platform != 'darwin'" }, @@ -2508,7 +2848,7 @@ name = "triton" version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8d/a9/549e51e9b1b2c9b854fd761a1d23df0ba2fbc60bd0c13b489ffa518cfcb7/triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e", size = 155600257, upload-time = "2025-05-29T23:39:36.085Z" }, @@ -2588,6 +2928,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, ] +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0" @@ -2690,7 +3046,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -2710,7 +3066,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -2730,7 +3086,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -2750,7 +3106,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -2770,7 +3126,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -2790,7 +3146,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -2810,7 +3166,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -2830,7 +3186,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -2850,7 +3206,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ From 33ce5b09a4fd87d1662a8e10b722aeb4565291fd Mon Sep 17 00:00:00 2001 From: Kesavan Date: Tue, 16 Jun 2026 18:43:30 -0400 Subject: [PATCH 061/200] fix(timelapse): accept comma-separated embryo_ids string in start() When an agent tool call passed embryo_ids as "embryo_1,embryo_2,..." (a string) instead of a JSON list, start() iterated the string character by character and reported every letter as a missing embryo ("Embryos not found: ['e','m','b','r','y','o',...]"). Coerce a string into a list by splitting on commas before the membership check. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/orchestration/timelapse.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index f4a2692a..6b140922 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -231,6 +231,13 @@ async def start( # Parse stop condition stop_cond = self._parse_stop_condition(stop_condition, condition_value) + # Tolerate a comma-separated string: some agent tool calls pass + # embryo_ids as "embryo_1,embryo_2" rather than a JSON list. Without + # this, the membership check below iterates the string character by + # character and reports every letter as a missing embryo. + if isinstance(embryo_ids, str): + embryo_ids = [e.strip() for e in embryo_ids.split(",") if e.strip()] + # Get embryo list if not embryo_ids: embryo_ids = [e.id for e in self.experiment.embryos.values() if not e.should_skip] From abb11fb35924d346802e9cb5c80c57daf48378f1 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Tue, 16 Jun 2026 18:48:52 -0400 Subject: [PATCH 062/200] fix(web): emit unpadded embryo ids to match the live convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web canvas → server path fabricated a zero-padded id (f"embryo_{number:03d}") whenever a marker arrived without an embryo_id, producing ids like "embryo_002". Everything else — detection_tools, the orchestrator, all tool examples — uses the unpadded f"embryo_{n}" form, so the padded ids never matched the stored "embryo_2" and surfaced as "Embryos not found" in start_adaptive_timelapse. Emit unpadded ids here too. The legacy SQLite store (core/database.py) still uses :03d but is retired/read-only; the state.py lookup shim that tries both formats stays as a safety net for any already-stored padded ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index daf91dba..16641eae 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -676,7 +676,11 @@ async def wait_for_marking(self, session_id: str, timeout: float | None = None) embryos.append( { "embryo_number": m["number"], - "embryo_id": m.get("embryo_id") or f"embryo_{m['number']:03d}", + # Unpadded to match the live convention used everywhere else + # (detection_tools registers embryos as f"embryo_{n}"). A + # zero-padded fallback here produced ids like "embryo_002" + # that never matched the stored "embryo_2". + "embryo_id": m.get("embryo_id") or f"embryo_{m['number']}", "pixel_position": (px, py), "pixel_x": px, "pixel_y": py, From 68a7833aec4599d63c9a988f31ec2031f53dca77 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 04:26:18 +0530 Subject: [PATCH 063/200] lint: wrap long lines + ruff-format notebook modules (E501) ruff check . and ruff format --check . both clean. --- gently/harness/memory/notebook.py | 26 ++++++------- gently/harness/memory/notebook_ask.py | 8 ++-- tests/test_notebook_api.py | 20 ++++++++-- tests/test_notebook_ask.py | 16 ++++++-- tests/test_notebook_store.py | 54 +++++++++++++++++++-------- 5 files changed, 81 insertions(+), 43 deletions(-) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 7f3176d9..88271fa8 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -24,8 +24,8 @@ class NoteKind(str, Enum): OBSERVATION = "observation" # immutable record of what was seen/done/read/noted - FINDING = "finding" # revisable, supersedable believed claim - QUESTION = "question" # open inquiry; large ones are the thread spine + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine class Author(str, Enum): @@ -35,10 +35,10 @@ class Author(str, Enum): class NoteStatus(str, Enum): - OPEN = "open" # questions not yet answered - PROPOSED = "proposed" # agent-drafted finding awaiting human confirm - CONFIRMED = "confirmed" # accepted observation/finding (default) - ANSWERED = "answered" # question resolved + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved SUPERSEDED = "superseded" # replaced by a newer note @@ -55,9 +55,9 @@ class Note: embryos: list[str] = field(default_factory=list) sessions: list[str] = field(default_factory=list) threads: list[str] = field(default_factory=list) - basis: list[str] = field(default_factory=list) # note ids this rests on - links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] - artifacts: list[dict] = field(default_factory=list) # FileStore pointers + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers superseded_by: str | None = None created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) @@ -151,9 +151,7 @@ def __init__(self, notebook_dir: Path): self.notes_dir.mkdir(parents=True, exist_ok=True) self.index_dir.mkdir(parents=True, exist_ok=True) # reverse indexes: facet -> {value: [note_id, ...]} - self._index: dict[str, dict[str, list[str]]] = { - "strain": {}, "embryo": {}, "thread": {} - } + self._index: dict[str, dict[str, list[str]]] = {"strain": {}, "embryo": {}, "thread": {}} self.rebuild_index() # ---- reverse indexes (notes are authoritative; indexes are caches) ---- @@ -250,9 +248,7 @@ def query_notes( scope_sets.append(set(self.ids_for_embryo(embryo))) if thread is not None: scope_sets.append(set(self.ids_for_thread(thread))) - candidate_ids: set[str] | None = ( - set.intersection(*scope_sets) if scope_sets else None - ) + candidate_ids: set[str] | None = set.intersection(*scope_sets) if scope_sets else None # 2. load + filter if candidate_ids is not None: diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py index acfed322..e8f1228c 100644 --- a/gently/harness/memory/notebook_ask.py +++ b/gently/harness/memory/notebook_ask.py @@ -45,7 +45,9 @@ def select_notes( "suggested_next": { "type": "array", "items": {"type": "string"}, - "description": "Concrete next experiments/moves if the question asks what to do; else empty.", + "description": ( + "Concrete next experiments/moves if the question asks what to do; else empty." + ), }, "coverage": { "type": "string", @@ -80,9 +82,7 @@ def _render_notes(notes: list[Note]) -> str: def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: body = ( - "Notebook entries:\n" - + _render_notes(notes) - + f"\n\nQuestion: {question}\n\n" + "Notebook entries:\n" + _render_notes(notes) + f"\n\nQuestion: {question}\n\n" "Answer using only these entries, citing note ids." ) return [{"role": "user", "content": body}] diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index 1d18333b..408fc423 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -23,8 +23,16 @@ class _Server: def _seed(cs): nb = cs.notebook nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) - nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma", - status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + nb.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="rings by comma", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) return cs @@ -135,8 +143,12 @@ class _Server: class TestAsk: def test_ask_returns_grounded_answer(self, file_context_store): cs = _seed(file_context_store) - canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}], - "suggested_next": [], "coverage": "covered"} + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } client = TestClient(_make_app_with_client(cs, _AskClient(canned))) resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) assert resp.status_code == 200 diff --git a/tests/test_notebook_ask.py b/tests/test_notebook_ask.py index 9f501090..0cf664d7 100644 --- a/tests/test_notebook_ask.py +++ b/tests/test_notebook_ask.py @@ -13,8 +13,12 @@ def _seed(cs): nb = cs.notebook - nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])) - nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])) + nb.write_note( + Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"]) + ) + nb.write_note( + Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"]) + ) nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) return nb @@ -71,8 +75,12 @@ def test_build_messages_embeds_note_ids(self): assert "o1" in text and "ring formed" in text and "what formed?" in text def test_answer_returns_structured_and_forces_tool(self): - canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}], - "suggested_next": [], "coverage": "covered"} + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } client = _FakeClient(canned) notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] out = asyncio.run(answer_question(client, "m", "what formed?", notes)) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 4fdd48f7..ff8cc715 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -6,9 +6,9 @@ from gently.harness.memory.notebook import ( Author, Note, + NotebookStore, NoteKind, NoteStatus, - NotebookStore, learning_to_note, note_from_dict, note_to_dict, @@ -21,8 +21,8 @@ def test_round_trip_minimal(self): n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") d = note_to_dict(n) assert d["kind"] == "observation" - assert d["author"] == "agent" # default - assert d["status"] == "confirmed" # default + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default back = note_from_dict(d) assert back == n @@ -79,15 +79,18 @@ class TestNotebookIndex: def test_index_updated_on_write(self, tmp_path): store = NotebookStore(tmp_path / "notebook") a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) - b = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])) + b = store.write_note( + Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"]) + ) assert set(store.ids_for_strain("N2")) == {a, b} assert store.ids_for_strain("OH904") == [b] assert store.ids_for_strain("missing") == [] def test_index_by_embryo_and_thread(self, tmp_path): store = NotebookStore(tmp_path / "notebook") - a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a", - embryos=["e1"], threads=["t1"])) + a = store.write_note( + Note(id="", kind=NoteKind.FINDING, body="a", embryos=["e1"], threads=["t1"]) + ) assert store.ids_for_embryo("e1") == [a] assert store.ids_for_thread("t1") == [a] @@ -103,10 +106,19 @@ class TestNotebookQuery: def _seed(self, tmp_path): store = NotebookStore(tmp_path / "notebook") store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) - store.write_note(Note(id="f1", kind=NoteKind.FINDING, body="f", - status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) - store.write_note(Note(id="q1", kind=NoteKind.QUESTION, body="q", - status=NoteStatus.OPEN, threads=["t1"])) + store.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="f", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) + store.write_note( + Note(id="q1", kind=NoteKind.QUESTION, body="q", status=NoteStatus.OPEN, threads=["t1"]) + ) return store def test_query_by_kind(self, tmp_path): @@ -161,9 +173,14 @@ def test_supersede_marks_old_and_points_new(self, tmp_path): class TestConverters: def test_observation_to_note(self): obs = Observation( - id="o1", timestamp=datetime(2026, 6, 16, 9, 0, 0), type="milestone", - content="nerve ring formed", embryo_id="e1", session_id="s1", - relates_to=["o0"], gently_refs={"kind": "projection", "t": 42}, + id="o1", + timestamp=datetime(2026, 6, 16, 9, 0, 0), + type="milestone", + content="nerve ring formed", + embryo_id="e1", + session_id="s1", + relates_to=["o0"], + gently_refs={"kind": "projection", "t": 42}, ) n = observation_to_note(obs) assert n.id == "o1" @@ -182,7 +199,7 @@ def test_learning_to_note(self): assert n.id == "l1" assert n.kind == NoteKind.FINDING assert n.body == "rings form by comma" - assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm assert n.confidence == Confidence.HIGH @@ -200,8 +217,13 @@ def test_apply_updates_mirrors_observations_and_learnings(self, file_context_sto from gently.harness.memory.model import ContextUpdates cs = file_context_store - obs = Observation(id="o1", timestamp=datetime(2026, 6, 16, 9, 0, 0), - type="milestone", content="ring formed", embryo_id="e1") + obs = Observation( + id="o1", + timestamp=datetime(2026, 6, 16, 9, 0, 0), + type="milestone", + content="ring formed", + embryo_id="e1", + ) lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) From d2d61b80c5051967b6fa232c518b6cb4efbdc22c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 14:59:05 +0530 Subject: [PATCH 064/200] =?UTF-8?q?feat(notebook):=20record=5Fnote=20tool?= =?UTF-8?q?=20=E2=80=94=20agent=20writes=20human=20notes=20to=20the=20note?= =?UTF-8?q?book=20(connection=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's first notebook *write* tool (memory_tools had only recall_*). When the user says 'note that…', the agent tidies the phrasing and saves a human-authored Note tagged to the current session (+ embryos/strains), then emits CONTEXT_UPDATED so the Notebook tab and Agent's-view live edge refresh. Reliable (human content), no perception guesswork; the agentic session-summariser is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/tools/memory_tools.py | 54 ++++++++++++++++++++++++++++++++ tests/test_record_note.py | 39 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_record_note.py diff --git a/gently/app/tools/memory_tools.py b/gently/app/tools/memory_tools.py index d58d3e5d..401d0bec 100644 --- a/gently/app/tools/memory_tools.py +++ b/gently/app/tools/memory_tools.py @@ -129,3 +129,57 @@ async def recall_context( if not memory: return "No memory available (context store not connected)" return memory.recall_full_context(campaign_id=campaign_id) + + +@tool( + name="record_note", + description=( + "Record a note from the researcher into the shared lab notebook. Use this when the " + "user says 'note that…', 'add a note…', 'remember that…'. First tidy the phrasing for " + "clarity — keep their meaning and any specifics (numbers, strains, stages) — then save. " + "The note is attributed to the human and tagged to the current session." + ), + category=ToolCategory.UTILITY, + examples=[ + ToolExample( + user_query="Note that the 4-embryo test ran clean — 329 timepoints, system nominal.", + tool_input={ + "text": "Test run on 4 calibration embryos was clean: 329 timepoints, " + "system behaved as expected." + }, + ), + ], +) +async def record_note( + text: str, + embryos: list[str] | None = None, + strains: list[str] | None = None, + context: dict | None = None, +) -> str: + """Write a human-authored note into the notebook, tagged to the current session.""" + agent = context.get("agent") if context else None + cs = getattr(agent, "context_store", None) if agent else None + if cs is None: + return "No notebook available (context store not connected)" + from gently.harness.memory.notebook import Author, Note, NoteKind + + session_id = getattr(agent, "session_id", None) + note = Note( + id="", + kind=NoteKind.OBSERVATION, + body=text, + author=Author.HUMAN, + sessions=[session_id] if session_id else [], + embryos=embryos or [], + strains=strains or [], + ) + note_id = cs.notebook.write_note(note) + # Refresh the Notebook tab + Agent's-view live edge (both ride CONTEXT_UPDATED). + try: + from gently.core.event_bus import EventType, emit + + emit(EventType.CONTEXT_UPDATED, {"kind": "note"}, source="record_note") + except Exception: + pass + scope = "this session" if session_id else "the notebook (no active session)" + return f"Noted (id {note_id}) — saved to {scope}, attributed to you." diff --git a/tests/test_record_note.py b/tests/test_record_note.py new file mode 100644 index 00000000..ddad3559 --- /dev/null +++ b/tests/test_record_note.py @@ -0,0 +1,39 @@ +"""Tests for the record_note tool — human notes into the shared notebook.""" + +import asyncio +from types import SimpleNamespace + +import gently.app.tools.memory_tools # noqa: F401 — registers record_note +from gently.harness.tools.registry import get_tool_registry + + +def _handler(): + return get_tool_registry().get("record_note").handler + + +class TestRecordNote: + def test_writes_human_note_tagged_to_session(self, file_context_store): + agent = SimpleNamespace(context_store=file_context_store, session_id="sess_1", memory=None) + out = asyncio.run( + _handler()(text="ring formed cleanly", embryos=["e1"], context={"agent": agent}) + ) + assert "Noted" in out + notes = file_context_store.notebook.query_notes() + assert len(notes) == 1 + n = notes[0] + assert n.author.value == "human" + assert n.body == "ring formed cleanly" + assert n.sessions == ["sess_1"] + assert n.embryos == ["e1"] + + def test_no_session_still_records(self, file_context_store): + agent = SimpleNamespace(context_store=file_context_store, session_id=None, memory=None) + asyncio.run(_handler()(text="general observation", context={"agent": agent})) + notes = file_context_store.notebook.query_notes() + assert len(notes) == 1 + assert notes[0].sessions == [] + + def test_no_store_returns_message(self): + agent = SimpleNamespace(context_store=None, session_id=None, memory=None) + out = asyncio.run(_handler()(text="x", context={"agent": agent})) + assert "No notebook available" in out From 83b825fe8c5b8a4bb324a2100f4a94312cbd65c9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 15:20:02 +0530 Subject: [PATCH 065/200] =?UTF-8?q?feat(plans):=20live=20plan=20UI=20?= =?UTF-8?q?=E2=80=94=20PLAN=5FUPDATED=20event=20+=20campaigns.js=20refresh?= =?UTF-8?q?=20(connection=20A,=20live-UI=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-item/campaign writes (create/update/complete item, session-campaign link, progress) now emit PLAN_UPDATED; the server already broadcasts it to /ws, and campaigns.js subscribes + re-fetches the open campaign (debounced, preserving the selected item). Fixes 'agent changed the plan but the UI didn't move'. The execute_plan_item link-ordering fix (overlaps the production branch) is separate. --- gently/core/event_bus.py | 4 ++++ gently/harness/memory/file_store.py | 15 +++++++++++++ gently/ui/web/static/js/campaigns.js | 26 ++++++++++++++++++++++ tests/test_plan_events.py | 32 ++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 tests/test_plan_events.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index d3ab0e7a..ce3aac03 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -97,6 +97,10 @@ class EventType(Enum): # drives the shared-visibility surface in the v2 UI. CONTEXT_UPDATED = auto() + # Plan/campaign mutated (item status, session link, new item, progress) — + # drives live refresh of the Plans UI. + PLAN_UPDATED = auto() + # Operator-action events. Distinct from EMBRYOS_UPDATE because they # carry intent ("a human did this") rather than just state delta. # Candidate orchestrators can subscribe and reason about what the diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 7c153073..c1b38f0b 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -503,6 +503,7 @@ def update_campaign_progress(self, campaign_id: str, progress: str): data["progress"] = progress data["updated_at"] = self._now() self._write_yaml(folder / "campaign.yaml", data) + self._notify_plan_change(campaign_id) def update_campaign_status(self, campaign_id: str, status: Status): folder = self._campaign_folder(campaign_id) @@ -832,6 +833,7 @@ def link_session_campaign(self, session_id: str, campaign_id: str): cids.append(campaign_id) data["campaign_ids"] = cids self._write_yaml(path, data) + self._notify_plan_change(campaign_id) def unlink_session_campaign(self, session_id: str, campaign_id: str): path = self.agent_dir / "session_intents" / f"{session_id}.yaml" @@ -1156,6 +1158,7 @@ def create_plan_item( } items.append(item_data) self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) logger.info(f"Created plan item {pid} [{type}] #{phase_order}: {title}") return pid @@ -1336,10 +1339,12 @@ def update_plan_item( new_items = self._read_plan_items_raw(campaign_id) new_items.append(item) self._write_plan_items(campaign_id, new_items) + self._notify_plan_change(campaign_id) return item["updated_at"] = self._now() self._write_plan_items(old_campaign_id, items) + self._notify_plan_change(old_campaign_id) def complete_plan_item(self, item_id: str, outcome: str): self.update_plan_item( @@ -1923,6 +1928,16 @@ def _notify_context_change(self, kind: str = "context") -> None: except Exception: pass + def _notify_plan_change(self, campaign_id: str | None = None) -> None: + """Emit PLAN_UPDATED so the Plans UI refreshes live when a plan item or + campaign changes (status, session link, new item, progress). Best-effort.""" + try: + from gently.core.event_bus import EventType, emit + + emit(EventType.PLAN_UPDATED, {"campaign_id": campaign_id}, source="context_store") + except Exception: + pass + def add_expectation(self, exp: Expectation): path = self.agent_dir / "active" / "expectations.yaml" items = self._read_yaml(path) or [] diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index da1bd515..dd68e54a 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -132,6 +132,13 @@ function boot() { // Plan view switcher setupPlanViewSwitcher(); + // Live refresh: re-fetch the active campaign when the plan changes (item status, + // session link, new item, progress). The store emits PLAN_UPDATED, the server + // broadcasts it to /ws, and websocket.js re-emits it on the client bus. + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('PLAN_UPDATED', () => scheduleCampaignRefresh()); + } + // Load campaigns — auto-selects first, or the specified one const initialId = window.INITIAL_CAMPAIGN_ID; if (initialId) { @@ -268,6 +275,25 @@ async function openCampaign(campaignId) { renderAll(); } +// Live refresh of the open campaign (debounced) — re-fetch its tree and re-render, +// preserving the selected item so the inspector reflects the change without a reload. +let _planRefreshTimer = null; +function scheduleCampaignRefresh() { + if (_planRefreshTimer) clearTimeout(_planRefreshTimer); + _planRefreshTimer = setTimeout(() => { + _planRefreshTimer = null; + refreshActiveCampaign().catch(() => {}); + }, 400); +} +async function refreshActiveCampaign() { + if (!state.activeCampaignId) return; + const keep = state.selectedItemId; + await loadDocument(state.activeCampaignId); + if (!state.docData) return; + renderAll(); + if (keep) selectItem(keep).catch(() => {}); +} + // Handle browser back/forward window.addEventListener('popstate', e => { const s = e.state; diff --git a/tests/test_plan_events.py b/tests/test_plan_events.py new file mode 100644 index 00000000..f91b786a --- /dev/null +++ b/tests/test_plan_events.py @@ -0,0 +1,32 @@ +"""Plan writes emit PLAN_UPDATED so the Plans UI can refresh live.""" + +from gently.core.event_bus import EventType, on +from gently.harness.memory.model import PlanItemStatus + + +class TestPlanUpdatedEvent: + def test_plan_writes_emit_plan_updated(self, file_context_store): + cs = file_context_store + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + cid = cs.create_campaign(description="test campaign") + iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="pilot") + cs.update_plan_item(iid, status=PlanItemStatus.IN_PROGRESS, session_id="sess_1") + cs.complete_plan_item(iid, outcome="done") + finally: + unsub() + # create_plan_item + update_plan_item + complete_plan_item(→update) each fire it + assert len(seen) >= 3 + assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + def test_session_campaign_link_emits(self, file_context_store): + cs = file_context_store + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + cid = cs.create_campaign(description="c") + cs.link_session_campaign("sess_9", cid) + finally: + unsub() + assert any((e.data or {}).get("campaign_id") == cid for e in seen) From e1f698e703b2902c732e216ad6e98d0d6b849f55 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 15:29:08 +0530 Subject: [PATCH 066/200] =?UTF-8?q?fix(plans):=20reliable=20session?= =?UTF-8?q?=E2=86=94item=20linking,=20one=20item=20=E2=86=94=20many=20sess?= =?UTF-8?q?ions=20(connection=20A,=20link-fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_plan_item linked before the session existed (agent.session_id was None) and swallowed errors → running from a spec never linked. Now: start first, then link (re-read session_id), append via link_plan_item_session, surface failures. Same item-level link added to attach_session_to_plan. PlanItem gains session_ids (an item can run many sessions: re-runs/multi-sitting); session_id kept as the latest for back-compat. All link writes emit PLAN_UPDATED (live UI). --- gently/app/tools/plan_execution_tools.py | 38 ++++++++++++------------ gently/app/tools/resolution_tools.py | 6 ++-- gently/harness/memory/file_store.py | 29 ++++++++++++++++++ gently/harness/memory/model.py | 3 +- tests/test_plan_events.py | 17 +++++++++++ 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/gently/app/tools/plan_execution_tools.py b/gently/app/tools/plan_execution_tools.py index f07e8af0..6fba52e1 100644 --- a/gently/app/tools/plan_execution_tools.py +++ b/gently/app/tools/plan_execution_tools.py @@ -107,24 +107,9 @@ async def execute_plan_item( except Exception as e: actions.append(f"detector '{det_name}' failed: {e}") - # 6. Link session to campaign - session_id = getattr(agent, "session_id", None) - if session_id: - try: - cs.link_session_campaign(session_id, item.campaign_id) - actions.append("session linked to campaign") - except Exception: - pass - - # 7. Update plan item status - cs.update_plan_item( - item_id=item.id, - status=PlanItemStatus.IN_PROGRESS, - session_id=session_id, - ) - actions.append("plan item status → in_progress") - - # 8. Start timelapse via orchestrator + # 6. Start timelapse via orchestrator — this is what activates the session, so the + # plan↔session link must happen AFTER it (step 7), not before (the old bug: + # agent.session_id was still None here, so the link silently dropped). orchestrator = getattr(agent, "timelapse_orchestrator", None) if orchestrator: try: @@ -158,7 +143,22 @@ async def execute_plan_item( except Exception as e: actions.append(f"timelapse start error: {e}") - # 10. Summary + # 7. Link this run to the plan item + campaign — AFTER start, so the session exists. + # Appends (an item may run several sessions). Surface failures, don't swallow. + session_id = getattr(agent, "session_id", None) + if session_id: + try: + cs.link_session_campaign(session_id, item.campaign_id) + cs.link_plan_item_session(item.id, session_id) + actions.append( + f"linked session {session_id[:8]} → plan item + campaign (status → in_progress)" + ) + except Exception as e: + actions.append(f"⚠ link failed: {e}") + else: + actions.append("⚠ no active session — could not link this run to the plan item") + + # Summary lines = [f"Executing plan item: {item.title}"] if spec.strain: lines.append(f" Strain: {spec.strain}") diff --git a/gently/app/tools/resolution_tools.py b/gently/app/tools/resolution_tools.py index c3a152da..3ae8b4cc 100644 --- a/gently/app/tools/resolution_tools.py +++ b/gently/app/tools/resolution_tools.py @@ -112,15 +112,17 @@ async def attach_session_to_plan( # Two sources of truth for the active plan item _set_active_plan_item(agent, item.id) - # Link the session into the campaign's intent record + # Link the session into the campaign's intent record AND onto the plan item + # itself (item-level, appends — an item may have several sessions). session_id = getattr(agent, "session_id", None) linked = False if session_id: try: cs.link_session_campaign(session_id, item.campaign_id) + cs.link_plan_item_session(item.id, session_id) linked = True except Exception as e: - logger.warning(f"link_session_campaign failed: {e}") + logger.warning(f"session↔plan link failed: {e}") # Invalidate prompt cache so the next system prompt picks up the # active item (the memory awareness layer injects its spec). diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index c1b38f0b..52607253 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1147,6 +1147,7 @@ def create_plan_item( "inherit_from": inherit_from, "planned_session_id": planned_session_id, "session_id": None, + "session_ids": [], "estimated_days": estimated_days, "phase_order": phase_order, "references": references, @@ -1353,6 +1354,33 @@ def complete_plan_item(self, item_id: str, outcome: str): outcome=outcome, ) + def link_plan_item_session( + self, item_id: str, session_id: str, set_in_progress: bool = True + ) -> bool: + """Attach a session to a plan item — APPENDS (an item may run several times: + re-runs, multi-sitting, more embryos later). Records the session as the latest + `session_id` (back-compat), flips a PLANNED item to IN_PROGRESS, emits PLAN_UPDATED. + Returns False if the item isn't found.""" + loc = self._find_plan_item_location(item_id) + if not loc: + return False + campaign_id, items, idx = loc + item = items[idx] + sids = item.get("session_ids") or ( + [item["session_id"]] if item.get("session_id") else [] + ) + if session_id and session_id not in sids: + sids.append(session_id) + item["session_ids"] = sids + if sids: + item["session_id"] = sids[-1] # most recent run; back-compat for older readers + if set_in_progress and item.get("status") == "planned": + item["status"] = PlanItemStatus.IN_PROGRESS.value + item["updated_at"] = self._now() + self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) + return True + def skip_plan_item(self, item_id: str, reason: str | None = None): self.update_plan_item( item_id, @@ -2620,6 +2648,7 @@ def _dict_to_plan_item(d: dict) -> PlanItem: bench_spec=bench_spec, planned_session_id=d.get("planned_session_id"), session_id=d.get("session_id"), + session_ids=d.get("session_ids") or ([d["session_id"]] if d.get("session_id") else []), inherit_from=d.get("inherit_from"), estimated_days=d.get("estimated_days"), phase_order=d.get("phase_order", 0), diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index 3757a0fe..e9e6532d 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -292,7 +292,8 @@ class PlanItem: # Linking planned_session_id: str | None = None # → PlannedSession (for imaging items) - session_id: str | None = None # → Actual session (once executed) + session_id: str | None = None # → most recent actual session (back-compat / "primary") + session_ids: list[str] = field(default_factory=list) # all sessions that ran this item (1→many) inherit_from: str | None = None # PlanItem ID to inherit spec from # Scheduling — relative timeline from Day 0 diff --git a/tests/test_plan_events.py b/tests/test_plan_events.py index f91b786a..ca28f9a9 100644 --- a/tests/test_plan_events.py +++ b/tests/test_plan_events.py @@ -30,3 +30,20 @@ def test_session_campaign_link_emits(self, file_context_store): finally: unsub() assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + +class TestLinkPlanItemSession: + def test_append_many_sessions(self, file_context_store): + cs = file_context_store + cid = cs.create_campaign(description="c") + iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="x") + assert cs.link_plan_item_session(iid, "s1") is True + assert cs.link_plan_item_session(iid, "s2") is True + cs.link_plan_item_session(iid, "s1") # duplicate — no-op + item = cs.get_plan_item(iid) + assert item.session_ids == ["s1", "s2"] # appended, deduped + assert item.session_id == "s2" # latest, for back-compat readers + assert item.status == PlanItemStatus.IN_PROGRESS # PLANNED → IN_PROGRESS on first link + + def test_missing_item_returns_false(self, file_context_store): + assert file_context_store.link_plan_item_session("nope", "s1") is False From a3b649c74eb6f1d2ab8a2a1aedaff5bf954ca02d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 15:38:16 +0530 Subject: [PATCH 067/200] lint: ruff-format file_store.py (fixes CI format check on integration) --- gently/harness/memory/file_store.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 52607253..ececeba2 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1366,9 +1366,7 @@ def link_plan_item_session( return False campaign_id, items, idx = loc item = items[idx] - sids = item.get("session_ids") or ( - [item["session_id"]] if item.get("session_id") else [] - ) + sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else []) if session_id and session_id not in sids: sids.append(session_id) item["session_ids"] = sids From 0d1cdebaf44fed909b71a548d69662cd51dc0ba6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 15:38:16 +0530 Subject: [PATCH 068/200] feat(plans): plan narrative in agent context + fix provenance spec-table render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection B: the run-mode awareness summary now carries the investigation goal, phase, and what's next (incl. decision-point flag) — not just the active item's spec sheet — so the agent runs as a scientist inside the experiment. Laser power was always in the brief/inspector; it just wasn't *set* in plans (connection D will let you fill it). Also fixes renderSpecTable showing 'provenance [object Object]' — nested objects are now skipped. --- gently/harness/memory/interface.py | 35 ++++++++++++++++++++++++---- gently/ui/web/static/js/campaigns.js | 3 +++ tests/test_plan_context.py | 29 +++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/test_plan_context.py diff --git a/gently/harness/memory/interface.py b/gently/harness/memory/interface.py index 2e27db8d..d6824a69 100644 --- a/gently/harness/memory/interface.py +++ b/gently/harness/memory/interface.py @@ -215,15 +215,42 @@ def get_awareness_summary(self) -> str: spec = self.store.resolve_imaging_spec(item) campaign = self.store.get_campaign(item.campaign_id) campaign_name = _short_name(campaign) if campaign else "?" + # Walk to the root campaign for the overall goal (the item's + # campaign may be a phase under it). + root = campaign + seen_ids: set[str] = set() + while root and root.parent_id and root.parent_id not in seen_ids: + seen_ids.add(root.id) + root = self.store.get_campaign(root.parent_id) lines.append(f"\n## Active Plan Item: {item.title}") - lines.append(f"Campaign: {campaign_name}") + if root and root.target: + lines.append(f"Goal of the investigation: {root.target}") + if campaign and root and campaign.id != root.id: + lines.append(f"Phase: {campaign_name}") + lines.append(f"Campaign: {_short_name(root) if root else campaign_name}") lines.append(f"Status: {item.status.value}") if spec: lines.append(self.format_imaging_spec_block(spec)) + # What's next — the items/gates this run unblocks. + try: + root_id = root.id if root else item.campaign_id + nxt = [ + u + for u in self.store.get_unblocked_plan_items(root_id) + if u.id != item.id + ][:3] + if nxt: + bits = [] + for u in nxt: + is_dp = u.type.value == "decision_point" + bits.append(u.title + (" (decision point)" if is_dp else "")) + lines.append("Next up: " + "; ".join(bits)) + except Exception: + pass lines.append( - "\nUse this spec when configuring embryos and " - "starting the timelapse. The user expects these " - "settings from their experimental plan." + "\nYou're executing this item within the plan above — use the " + "spec to configure and run, and keep the goal and what's next in " + "mind (you can make go/no-go calls). The user expects these settings." ) except Exception: pass diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index dd68e54a..16d1dce8 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -1077,6 +1077,9 @@ function renderSpecTable(spec) { let rows = ''; for (const [key, value] of Object.entries(spec)) { if (value == null || key.startsWith('_')) continue; + // Skip nested objects (e.g. provenance metadata) — they'd render as + // "[object Object]". Field-level provenance isn't a spec value to show here. + if (typeof value === 'object' && !Array.isArray(value)) continue; const label = SPEC_LABELS[key] || key; let display = Array.isArray(value) ? value.join(', ') : String(value); if (SPEC_UNITS[key]) display += SPEC_UNITS[key]; diff --git a/tests/test_plan_context.py b/tests/test_plan_context.py new file mode 100644 index 00000000..acf97ffe --- /dev/null +++ b/tests/test_plan_context.py @@ -0,0 +1,29 @@ +"""Connection B: the running agent's awareness summary carries the plan narrative +(goal + what's next), not just the active item's spec sheet.""" + +from gently.harness.memory.interface import AgentMemory + + +def test_awareness_includes_goal_and_next(file_context_store): + cs = file_context_store + cid = cs.create_campaign( + description="Pioneer guidance", target="how pioneers steer the nerve ring" + ) + active = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT baseline", + spec={"strain": "N2", "num_slices": 50, "laser_wavelength_nm": 488, "laser_power_pct": 8}, + ) + cs.create_plan_item(campaign_id=cid, type="decision_point", title="Go/no-go gate") + + mem = AgentMemory(cs, session_id="s1") + mem.active_plan_item_id = active + summary = mem.get_awareness_summary() + + assert "Goal of the investigation: how pioneers steer the nerve ring" in summary + assert "Next up:" in summary + assert "Go/no-go gate (decision point)" in summary + # spec block still present, incl. laser power when the field is set + assert "WT baseline" in summary + assert "Laser: 488nm at 8%" in summary From bb54ab6da5b5ab725aace579ed5400eae7d5782b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 16:02:32 +0530 Subject: [PATCH 069/200] Connection D: editable/fillable imaging specs in the plan inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the laser-power loop. The inspector was read-only and only surfaced fields that were already set, so a TBD value like laser power had no way in. - PATCH /api/campaigns/{cid}/items/{item_id} — edits item fields and merges partial imaging-spec updates (send one field, keep the rest; empty string clears). Routes through update_plan_item, which fires PLAN_UPDATED, so the edit shows live (connection A). - campaigns.js: ✎ Edit toggles the spec into a form listing every fillable field — empty ones included and flagged — PATCHes changed fields on Save, then re-fetches. Mid-edit guard so a live refresh doesn't clobber unsaved input. - tests: PATCH route (edit, spec-merge, clear, PLAN_UPDATED, 400/404). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/campaigns.py | 52 +++++++++ gently/ui/web/static/css/campaigns.css | 83 ++++++++++++++ gently/ui/web/static/js/campaigns.js | 149 ++++++++++++++++++++++++- tests/test_plan_item_patch.py | 96 ++++++++++++++++ 4 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 tests/test_plan_item_patch.py diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index a3fea08c..b0dbae11 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -321,6 +321,58 @@ async def _require(request: Request): return _require + @router.patch( + "/api/campaigns/{campaign_id}/items/{item_id}", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) + async def update_item(campaign_id: str, item_id: str, request: Request): + """Edit plan-item fields and/or imaging-spec fields inline. + + Send only the fields you're changing. Spec edits are *merged* into the + existing spec, so the UI can PATCH a single field (e.g. laser_power_pct) + without losing the rest. An empty string clears a spec field to null. + Persists via update_plan_item, which fires PLAN_UPDATED for live refresh. + """ + cs = _get_store() + _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Body must be a JSON object") + + kwargs: dict[str, Any] = {} + for f in ("title", "description", "outcome"): + if isinstance(body.get(f), str): + kwargs[f] = body[f] + if body.get("estimated_days") is not None: + kwargs["estimated_days"] = body["estimated_days"] + + if body.get("status"): + try: + kwargs["status"] = PlanItemStatus(body["status"]) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid status: {body['status']}" + ) from err + + spec_patch = body.get("spec") + if isinstance(spec_patch, dict): + current = item.imaging_spec or item.bench_spec + merged = asdict(current) if current else {} + for k, v in spec_patch.items(): + merged[k] = None if v == "" else v + kwargs["spec"] = merged + + if not kwargs: + raise HTTPException(status_code=400, detail="No editable fields supplied") + + cs.update_plan_item(item_id=item_id, **kwargs) # fires PLAN_UPDATED + updated = cs.get_plan_item(item_id) + return {"ok": True, "item": _serialize(updated)} + @router.post( "/api/campaigns/{campaign_id}/share", dependencies=[Depends(_make_campaign_auth("campaigns:admin"))], diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css index bc4f36c2..88690dec 100644 --- a/gently/ui/web/static/css/campaigns.css +++ b/gently/ui/web/static/css/campaigns.css @@ -1284,6 +1284,89 @@ font-size: 0.7rem; } +/* Section title with a right-aligned action (e.g. Edit) */ +.detail-section-title--row { + display: flex; + align-items: center; + justify-content: space-between; +} +.detail-section-action { display: inline-flex; } +.spec-edit-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.62rem; + letter-spacing: 0.3px; + cursor: pointer; +} +.spec-edit-btn:hover { color: var(--text); border-color: var(--text-muted); } + +/* Editable imaging-spec form */ +.spec-editor { display: flex; flex-direction: column; gap: 6px; } +.spec-edit-row { + display: grid; + grid-template-columns: 38% 1fr; + align-items: center; + gap: 10px; +} +.spec-edit-label { + color: var(--text-muted); + font-size: 0.7rem; +} +.spec-edit-row--empty .spec-edit-label::after { + content: ' • set'; + color: var(--accent-orange, #d98324); + font-size: 0.6rem; + opacity: 0.8; +} +.spec-edit-field { display: flex; align-items: center; gap: 5px; } +.spec-edit-input { + flex: 1; + min-width: 0; + background: var(--bg, #fff); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 7px; + color: var(--text); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.7rem; +} +.spec-edit-input:focus { + outline: none; + border-color: var(--accent, #2f6df6); +} +.spec-edit-row--empty .spec-edit-input { + border-style: dashed; +} +.spec-edit-unit { color: var(--text-muted); font-size: 0.66rem; } +.spec-edit-error { + color: var(--accent-orange, #d98324); + font-size: 0.68rem; + margin-top: 2px; +} +.spec-edit-actions { display: flex; gap: 8px; margin-top: 8px; } +.spec-save-btn, .spec-cancel-btn { + border-radius: 7px; + padding: 5px 14px; + font: inherit; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; +} +.spec-save-btn { + background: var(--accent, #2f6df6); + color: #fff; + border: 0; +} +.spec-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + /* Dependencies / Dependents chips */ .dep-list { display: flex; diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index 16d1dce8..6a3c3799 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -40,6 +40,18 @@ const SPEC_UNITS = { laser_power_pct: '%', interval_s: 's', estimated_duration_h: ' hrs', estimated_days: ' days', }; +// Imaging-spec fields the inspector lets you edit/fill inline (ordered for the form). +// Empty ones still show \u2014 that's how you fill a TBD value like laser power. +const IMAGING_SPEC_FIELDS = [ + 'strain', 'genotype', 'reporter', 'sample_prep', 'temperature_c', 'num_embryos', + 'num_slices', 'exposure_ms', 'laser_wavelength_nm', 'laser_power_pct', 'interval_s', + 'target_window', 'start_stage', 'stop_condition', 'estimated_duration_h', + 'success_criteria', 'comparison_to', +]; +const SPEC_NUMERIC = new Set([ + 'temperature_c', 'num_embryos', 'num_slices', 'exposure_ms', 'laser_wavelength_nm', + 'laser_power_pct', 'interval_s', 'estimated_duration_h', +]); // ── State ──────────────────────────────────────────────── const state = { @@ -52,6 +64,9 @@ const state = { versions: [], // snapshots list viewingSnapshotId: null, allItemsFlat: {}, // id → item for quick lookup + editingSpec: false, // inspector imaging-spec edit mode + _inspectorData: null, // last item-detail payload (for re-render on edit toggle) + _specError: '', // inline save error in the spec editor }; // ── DOM refs (cached on init) ──────────────────────────── @@ -123,6 +138,9 @@ function boot() { case 'back-to-current': backToCurrent(); break; case 'scroll-to': e.stopPropagation(); scrollCanvasTo(el.dataset.target); break; case 'toggle-phase': toggleNavPhase(el); break; + case 'spec-edit': e.stopPropagation(); startSpecEdit(); break; + case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break; + case 'spec-save': e.stopPropagation(); saveSpecEdit(); break; } }); @@ -291,7 +309,8 @@ async function refreshActiveCampaign() { await loadDocument(state.activeCampaignId); if (!state.docData) return; renderAll(); - if (keep) selectItem(keep).catch(() => {}); + // Don't clobber an in-progress spec edit with a re-fetch. + if (keep && !state.editingSpec) selectItem(keep).catch(() => {}); } // Handle browser back/forward @@ -606,6 +625,12 @@ function renderVersionHistory() { // ══════════════════════════════════════════════════════════ async function selectItem(itemId) { + // A re-fetch of the same item (e.g. after saving) keeps view mode; switching + // to a different item always lands in read-only. + if (itemId !== state.selectedItemId) { + state.editingSpec = false; + state._specError = ''; + } state.selectedItemId = itemId; // Highlight in document @@ -644,6 +669,7 @@ async function selectItem(itemId) { } function renderInspector(data) { + state._inspectorData = data; const item = data.item; const deps = data.dependencies || []; const dnts = data.dependents || []; @@ -689,9 +715,20 @@ function renderInspector(data) { html += section('Outcome', `
    ${esc(item.outcome)}
    `); } - // Imaging spec - if (item.imaging_spec) { - html += section('Imaging Specification', `${renderSpecTable(item.imaging_spec)}
    `); + // Imaging spec — view, or edit/fill inline (the laser-power loop). Shown for any + // imaging item even when no spec is set yet, so empty fields can be filled. + if (item.type === 'imaging' || item.imaging_spec) { + const spec = item.imaging_spec || {}; + if (state.editingSpec) { + html += section('Imaging Specification', renderSpecEditor(spec)); + } else { + const rows = renderSpecTable(spec); + const content = rows + ? `${rows}
    ` + : '
    No parameters set yet
    '; + const editBtn = ''; + html += section('Imaging Specification', content, editBtn); + } } // Bench spec @@ -767,6 +804,104 @@ function renderInspector(data) { if ($inspectorBody) $inspectorBody.innerHTML = html; } +// Editable imaging-spec form. Lists every fillable field — empty ones included, +// flagged — so a TBD value (e.g. laser power) is obvious and one click away. +function renderSpecEditor(spec) { + let rows = ''; + for (const key of IMAGING_SPEC_FIELDS) { + const label = SPEC_LABELS[key] || key; + const val = spec[key]; + const has = val != null && val !== ''; + const numeric = SPEC_NUMERIC.has(key); + const unit = SPEC_UNITS[key] + ? `${esc(SPEC_UNITS[key].trim())}` : ''; + const rowCls = has ? 'spec-edit-row' : 'spec-edit-row spec-edit-row--empty'; + rows += `
    + + + ${unit} + +
    `; + } + const err = state._specError + ? `
    ${esc(state._specError)}
    ` : ''; + return `
    + ${rows} + ${err} +
    + + +
    +
    `; +} + +function startSpecEdit() { + if (!state._inspectorData) return; + state.editingSpec = true; + state._specError = ''; + renderInspector(state._inspectorData); +} + +function cancelSpecEdit() { + state.editingSpec = false; + state._specError = ''; + if (state._inspectorData) renderInspector(state._inspectorData); +} + +// Collect changed/filled fields and PATCH them. The store fires PLAN_UPDATED, +// which live-refreshes the plan; we also re-fetch the inspector for immediacy. +async function saveSpecEdit() { + const data = state._inspectorData; + const item = data && data.item; + const campaignId = state.activeCampaignId; + if (!item || !campaignId) return; + + const orig = item.imaging_spec || {}; + const specPatch = {}; + document.querySelectorAll('#inspector-body [data-spec-key]').forEach(inp => { + const key = inp.dataset.specKey; + const raw = inp.value.trim(); + const hadVal = orig[key] != null && orig[key] !== ''; + if (raw === '') { + if (hadVal) specPatch[key] = ''; // cleared an existing value → unset + return; // stayed empty → skip + } + let v = raw; + if (SPEC_NUMERIC.has(key)) { + const n = Number(raw); + if (!Number.isNaN(n)) v = n; + } + if (String(orig[key] ?? '') !== String(v)) specPatch[key] = v; + }); + + state.editingSpec = false; + state._specError = ''; + if (Object.keys(specPatch).length === 0) { + selectItem(item.id).catch(() => {}); // nothing changed — just leave edit mode + return; + } + + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(item.id)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spec: specPatch }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + selectItem(item.id).catch(() => {}); // refresh inspector now; PLAN_UPDATED refreshes the plan + } catch (err) { + console.error('Failed to save spec:', err); + state.editingSpec = true; + state._specError = 'Could not save — try again.'; + renderInspector(data); + } +} + // Hand a planned imaging item to the agent to execute. The agent resolves the // item ref, applies its spec, and starts the timelapse (execute_plan_item). We // open the chat so the user sees it pick up and can confirm/adjust. @@ -1069,8 +1204,10 @@ function hideLoading() { $canvasLoading?.classList.add('hidden'); } -function section(title, content) { - return `
    ${title}
    ${content}
    `; +function section(title, content, headerAction) { + const extra = headerAction ? `${headerAction}` : ''; + const titleCls = headerAction ? 'detail-section-title detail-section-title--row' : 'detail-section-title'; + return `
    ${title}${extra}
    ${content}
    `; } function renderSpecTable(spec) { diff --git a/tests/test_plan_item_patch.py b/tests/test_plan_item_patch.py new file mode 100644 index 00000000..b6303f0c --- /dev/null +++ b/tests/test_plan_item_patch.py @@ -0,0 +1,96 @@ +"""Connection D: inline edits to plan items / imaging specs via PATCH. + +The inspector PATCHes changed fields; the store fires PLAN_UPDATED so the +Plans UI refreshes live. Spec edits merge into the existing spec. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _make_app(context_store): + from gently.ui.web.routes.campaigns import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed_imaging(cs): + cid = cs.create_campaign(description="c", target="goal") + iid = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT baseline", + spec={"strain": "N2", "laser_wavelength_nm": 488}, + ) + return cid, iid + + +class TestPatchItem: + def test_edit_title_and_status(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch( + f"/api/campaigns/{cid}/items/{iid}", + json={"title": "WT baseline (rev)", "status": "in_progress"}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + item = file_context_store.get_plan_item(iid) + assert item.title == "WT baseline (rev)" + assert item.status.value == "in_progress" + + def test_fill_laser_power_merges_spec(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch( + f"/api/campaigns/{cid}/items/{iid}", + json={"spec": {"laser_power_pct": 8}}, + ) + assert r.status_code == 200 + item = file_context_store.get_plan_item(iid) + # the filled field is set... + assert item.imaging_spec.laser_power_pct == 8 + # ...and the pre-existing spec fields survive the merge + assert item.imaging_spec.strain == "N2" + assert item.imaging_spec.laser_wavelength_nm == 488 + + def test_empty_string_clears_field(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"spec": {"strain": ""}}) + assert r.status_code == 200 + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.strain is None + + def test_patch_fires_plan_updated(self, file_context_store): + from gently.core.event_bus import EventType, on + + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"title": "x"}) + finally: + unsub() + assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + def test_no_fields_is_400(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={}) + assert r.status_code == 400 + + def test_missing_item_is_404(self, file_context_store): + cid, _ = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/nope", json={"title": "x"}) + assert r.status_code == 404 From 4d503f8c652c96d5659ba95d7030d39497fc2428 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 21:29:14 +0530 Subject: [PATCH 070/200] docs(temperature): design spec for temperature interface (sub-project A) Persistence (session-scoped temperature.jsonl + per-acquisition meta stamp) + live SVG graph (water trace, stepped setpoint) on a Devices-tab card. First slice of the temp-strain experiment prep roadmap (A before B/manual mode). Brainstormed and approved 2026-06-27. --- ...2026-06-27-temperature-interface-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-27-temperature-interface-design.md diff --git a/docs/superpowers/specs/2026-06-27-temperature-interface-design.md b/docs/superpowers/specs/2026-06-27-temperature-interface-design.md new file mode 100644 index 00000000..30de5138 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-temperature-interface-design.md @@ -0,0 +1,179 @@ +# Design: Temperature Interface (persistence + live graph) + +Status: design approved in brainstorm (2026-06-27). +Base branch: off `integration/ux2-all` (PR #58, the UX-v2 stack) — this feature builds on that UI. + +## 0. Where this sits (execution roadmap) + +This is **sub-project A** of a sequenced program of work, preparing gently for +production temperature-strain experiments next week. The full order (commit history is +meant to reflect this priority): + +- **Track 1 — critical path for next week** + - **A. Temperature interface** *(this spec)* — persist temperature + live graph. + - **B. Manual mode** — write-enabled control surface (illumination from MMConfig presets, + live camera view, volume/timelapse config + by-hand triggers, temperature setpoint + control). Embeds A's graph. +- **Track 2 — fast-follow (repeatability + observability)** + - **C. Temp-change burst tactic** — a `MonitoringMode` choreographing + transmission-burst → setpoint-change → bursts-during → bursts-after. + - **D. Operations-tab overhaul** — make composed tactics observable. +- **Track 4 — later infrastructure** + - **G. Tactics library** (user-/agent-authored, persisted, reusable tactics), then + - **F. Plan-mode files** (repo base plans + session↔plan link/delink UI). +- **Lowest** + - **E. Presentation / README.** + +Next week's first experiment round is **hand-driven** (A + B). A is foundational because +B embeds its graph and C/D both consume the persisted temperature series. + +## 1. Overview + +The temperature controller (ACUITYnano Peltier stage, `gently/hardware/temperature.py`) is +already integrated as a Bluesky device: `read()` returns water temp + setpoint + state live, +exposed over the device layer at `GET /api/temperature/status`, with `set_temperature` / +`get_temperature` agent tools. The web UI already has an SSE push channel +(`DEVICE_STATE_UPDATE`) and shows temperature read-only in `devices.js`. + +**Two things are missing, and this sub-project supplies exactly those two:** + +1. **Persistence** — temperature is live-only today. `ImagingSpec.temperature_c` is a + *planned setpoint*, never a recorded readout. Nothing writes actual temperature into the + session or burst files, so a burst cannot be correlated to "what was the temperature." + This is the one real data hole for the experiment. +2. **A live graph** — a continuous chart of current temperature, setpoint, and the rise/fall + trajectory over time. + +### What we keep / reuse +- The controller's existing `read()` and the device-layer `GET /api/temperature/status` route. +- The existing SSE push mechanism (`websocket.js` / `event-bus.js` / `status-store.js`). +- The file-based, human-browsable store convention (`FileStore`, append-only jsonl like + `timeline.jsonl` / `predictions.jsonl`). +- The hand-rolled-SVG charting style already used in `experiment-overview.js` / + `occupancy3d.js` — **no new charting dependency.** +- The "calm empty state, never mock data" UI convention. + +### Scope decision: session-scoped capture +Temperature is captured **only while an imaging session is active** (decided in brainstorm). +No always-on facility daemon, no between-session global log. The series belongs to the +session, which is where correlation matters. + +## 2. Architecture — five well-bounded units + +### 2.1 Temperature log store (persistence) +Append-only series, one file per session: `sessions/{session}/temperature.jsonl`. +Dedicated file — **not** folded into `timeline.jsonl`, because 1 Hz samples would drown the +event stream and a standalone series is trivial to read and plot. + +API (methods on / adjacent to `FileStore`, `gently/core/file_store.py`): +- `append_temperature_sample(session_id, sample)` — atomic append of one line. +- `read_temperature_log(session_id, since=None)` — return samples, optionally filtered to + those at/after a timestamp (for the graph's rolling window / backfill). + +Sample schema (one jsonl line): +```json +{"t": "2026-06-27T10:31:04.512Z", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} +``` +`state` mirrors the controller's reported state string (e.g. heating / cooling / locked). + +### 2.2 Temperature sampler (the capture loop) +A small background service in the **agent** process (persistence and session lifecycle are +agent-side). While a session is active it: +- polls the device layer's `GET /api/temperature/status` on a fixed cadence, +- appends each reading to the session's `temperature.jsonl` via 2.1, +- holds the **latest reading in memory** (so acquisition can stamp it without a blocking read), +- emits an SSE `TEMPERATURE_UPDATE` event for the live graph. + +Lifecycle: started/stopped with the session. If no temperature device is configured (the +`temperature:` block is config-gated in `device_layer.py`), the sampler does not run. + +Cadence: **1 Hz**, configurable via the existing `temperature:` config block. (Thermal change +is slow; 1 Hz gives a smooth trace without flooding the log.) + +### 2.3 Acquisition stamp (image↔temperature correlation) +At volume / burst acquisition, write the sampler's latest reading into the acquisition's +metadata: +- volume: `embryos/{id}/volumes/t{NNNN}.meta.yaml` +- burst: each frame `.meta.yaml` and the `burst.yaml` manifest + (`gently/app/orchestration/exclusive.py`). + +Stamped block: +```yaml +temperature: + water_c: 28.4 + setpoint_c: 32.0 + state: heating + sampled_at: 2026-06-27T10:31:04.512Z # timestamp of the reading, not of acquisition +``` +We stamp the **latest sample** rather than a fresh blocking device read — at 1 Hz it is +current enough, and acquisition stays latency-free. If no sample exists yet (session just +started), omit the `temperature` block rather than writing stale/zero values. + +### 2.4 Read API (graph backfill) +`GET /api/temperature/history?session=current&since=` returns the series for the initial +graph render and reloads (`session=current` resolves to the most-recently-touched session, +matching the existing experiments route convention). Live updates ride the existing SSE +channel via `TEMPERATURE_UPDATE`; the endpoint is only for backfill. + +### 2.5 Frontend component (the graph) +`gently/ui/web/static/js/temperature-graph.js` — a hand-rolled SVG line chart, reusable and +self-mounting, surfaced as a **card on the Devices tab** (where temperature already shows +read-only). B (manual mode) re-mounts the same component. + +Renders: +- **water-temp trace** (continuous line), +- **setpoint line** (distinct, stepped — so a commanded jump from 28→32 reads as a step while + the water trace climbs toward it), +- **current numeric readouts** (water, setpoint, state) above the chart, +- a **rolling time window** (last few minutes) with full-history fetch on demand. + +Data: backfills from 2.4 on mount, then appends from the `TEMPERATURE_UPDATE` SSE stream. + +## 3. Data flow + +``` +device layer (owns controller) + ▲ GET /api/temperature/status (poll @ 1 Hz, while session active) + │ + agent: TemperatureSampler ──► append temperature.jsonl (persistence) + │ └─► keep latest-in-memory ──► acquisition stamps meta + └─► emit SSE TEMPERATURE_UPDATE ──► browser: temperature-graph.js (live append) + +browser on mount/reload: GET /api/temperature/history?session=current ──► backfill graph +``` + +## 4. Error handling & edge cases + +- **No temperature device / device layer down** → sampler no-ops; graph shows a calm + "no temperature data" empty state (matching the codebase convention). Never mock data. +- **A failed poll** → log a gap and continue; a sampler error never crashes the session. +- **Partial last jsonl line** (atomic append + a reader tolerant of a truncated final line) → + the history endpoint skips an unparseable trailing line rather than erroring. +- **Acquisition before first sample** → omit the `temperature` block from meta (no stale/zero). +- **Setpoint change** → captured naturally: the next sample carries the new `setpoint_c`, so + the stepped setpoint line moves on its own; no special event needed. + +## 5. Testing (TDD) + +- **Store**: `append_temperature_sample` / `read_temperature_log` round-trip; `since` filter; + tolerance of a truncated trailing line. +- **Sampler**: against the existing mock controller scaffold + (`gently/hardware/dispim/devices/test_temperature_controller.py`) — samples at cadence, + appends, updates latest-in-memory, emits SSE; **no-device path** (sampler stays dormant); + a poll failure leaves a gap without raising. +- **Acquisition stamp**: a fake volume + burst acquisition stamps the latest sample into meta; + the no-sample-yet path omits the block. +- **History endpoint**: filters by `since`; `session=current` resolution. +- **Frontend**: a light render test for the SVG component — water trace + stepped setpoint + geometry, and the empty state when history is empty. + +## 6. Out of scope (deferred to later sub-projects) + +- **Setpoint *control* from the UI** — belongs to B (manual mode), which embeds this graph. +- **Automated temp-change choreography** — belongs to C (the tactic). +- **Always-on / facility-wide temperature logging** — explicitly not now (session-scoped only). + +## 7. Open defaults (easy to flip) + +- **1 Hz** sampling cadence. +- Stamp the **latest sample** (not a fresh blocking read) at acquisition time. From d1c881fd28b5b8b95ca30df26a72ab441678cf73 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 21:42:59 +0530 Subject: [PATCH 071/200] docs(temperature): TDD implementation plan for temperature interface (7 tasks) --- .../plans/2026-06-27-temperature-interface.md | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-temperature-interface.md diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md new file mode 100644 index 00000000..ab02b895 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -0,0 +1,793 @@ +# Temperature Interface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist live temperature into each imaging session and chart it, so bursts/volumes are correlatable to temperature and the rise/fall trajectory is visible during the temperature-strain experiments. + +**Architecture:** A `FileStore`-backed append-only `temperature.jsonl` per session (mirroring `predictions.jsonl`); a `TemperatureSampler(Service)` in the agent process — modeled on `DeviceStateMonitor` — that, while a session is active, polls the device layer at 1 Hz, appends each reading, holds the latest in memory, and publishes a `TEMPERATURE_UPDATE` event that the viz server already forwards to the browser; acquisition code stamps the latest reading into volume/burst metadata; a FastAPI history route backfills the graph; a hand-rolled SVG component renders water-temp + stepped-setpoint as a card on the Devices tab. + +**Tech Stack:** Python 3 + asyncio, FastAPI, the project's `EventBus`, `FileStore` (file-based YAML/JSONL), vanilla-JS + hand-rolled SVG frontend (no build step), pytest with `asyncio_mode = "auto"`. + +## Global Constraints + +- **No new dependency** — chart is hand-rolled SVG (`createElementNS`), matching `experiment-overview.js`. No charting library. +- **Session-scoped capture only** — sampler persists/emits **only while a session is active**; no always-on facility daemon. +- **Sample schema** (one JSONL line): `{"t": , "water_c": , "setpoint_c": , "state": }`. +- **Append pattern**: reuse `FileStore._append_jsonl` (append mode, `json.dumps(..., default=str)`, trailing `\n`). Meta written via existing `_write_yaml` / `yaml.safe_dump`. +- **Defaults (flippable):** 1 Hz sampling; stamp the **latest sample** (no fresh blocking read) at acquisition. +- **Robustness:** a failed poll = a gap, logged, loop continues; a sampler error never crashes the session. Empty state, **never mock data**. +- **Tests:** `pytest`; `async def test_*` needs no decorator (auto mode); use the `file_store` fixture (`tests/conftest.py:38-45`, `FileStore(tmp_path/...)`). The frontend has **no JS unit harness** — verify it by running the app + Chrome DevTools MCP. +- **Env:** production runs pip + `requirements*.txt` (no `uv`); we add no deps, so nothing to declare. + +--- + +### Task 1: Temperature log store (FileStore methods) + +**Files:** +- Modify: `gently/core/file_store.py` (add two methods on `FileStore`; reuse module-level `_append_jsonl` at `:197-201` and `_read_jsonl` at `:204-215`, and `_session_dir`/`_require_session_dir` at `:255-269`) +- Test: `tests/test_temperature_store.py` (new) + +**Interfaces:** +- Produces: + - `FileStore.append_temperature_sample(self, session_id: str, sample: dict) -> None` — appends one line to `sessions/{folder}/temperature.jsonl`. + - `FileStore.read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]` — returns samples; if `since` (an ISO-UTC string) is given, only samples with `r["t"] >= since` (lexicographic compare is valid for fixed-format UTC ISO). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_store.py +def _new_session(file_store): + return file_store.create_session(name="temp-test") # returns session_id + +def test_append_and_read_roundtrip(file_store): + sid = _new_session(file_store) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + +def test_read_since_filters(file_store): + sid = _new_session(file_store) + for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): + file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + +def test_read_unknown_session_is_empty(file_store): + assert file_store.read_temperature_log("does-not-exist") == [] +``` + +> NOTE for implementer: confirm the exact session-creation API on `FileStore` (search for `def create_session`). If its signature differs, adjust `_new_session` accordingly — the rest of the test is unaffected. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: FAIL — `AttributeError: 'FileStore' object has no attribute 'append_temperature_sample'` + +- [ ] **Step 3: Write minimal implementation** + +Add to the `FileStore` class body in `gently/core/file_store.py` (near the other per-session jsonl helpers like `add_prediction`): + +```python +def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _append_jsonl(sd / "temperature.jsonl", sample) + +def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: + """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).""" + sd = self._session_dir(session_id) + if sd is None: + return [] + rows = _read_jsonl(sd / "temperature.jsonl") + if since is not None: + rows = [r for r in rows if str(r.get("t", "")) >= since] + return rows +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/file_store.py tests/test_temperature_store.py +git commit -m "feat(temperature): session-scoped temperature.jsonl store on FileStore" +``` + +--- + +### Task 2: `TEMPERATURE_UPDATE` event type + +**Files:** +- Modify: `gently/core/event_bus.py` (add enum member near `:86`; add to `_NO_HISTORY_TYPES` near `:187`) +- Test: `tests/test_temperature_event.py` (new) + +**Interfaces:** +- Produces: `EventType.TEMPERATURE_UPDATE` (a new enum member). High-volume → excluded from event history. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_event.py +from gently.core.event_bus import EventType, EventBus + +def test_temperature_update_event_exists(): + assert EventType.TEMPERATURE_UPDATE.value == "TEMPERATURE_UPDATE" + +def test_temperature_update_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] +``` + +> NOTE: confirm `EventBus.subscribe` signature/usage from an existing test (`tests/` has event-bus usage); adjust the subscribe call if the project's API differs (e.g. `subscribe(event_type, handler)` vs `subscribe(handler, event_type)`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: FAIL — `AttributeError: TEMPERATURE_UPDATE` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/core/event_bus.py`, add the member alongside the other `EventType` values (mirror how `DEVICE_STATE_UPDATE` at `:86` is declared — match the existing value style, e.g. `TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE"`): + +```python + TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE" +``` + +And add it to the high-volume set so it is not retained in history (next to `DEVICE_STATE_UPDATE` in `_NO_HISTORY_TYPES` near `:187`): + +```python + EventType.TEMPERATURE_UPDATE, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py tests/test_temperature_event.py +git commit -m "feat(temperature): add TEMPERATURE_UPDATE event type" +``` + +--- + +### Task 3: TemperatureSampler service + +**Files:** +- Create: `gently/app/temperature_sampler.py` +- Test: `tests/test_temperature_sampler.py` (new) +- Reference (template, do not modify): `gently/app/device_state_monitor.py`, `gently/core/service.py:63-172` + +**Interfaces:** +- Consumes: `EventType.TEMPERATURE_UPDATE` (Task 2); `FileStore.append_temperature_sample` (Task 1); a microscope client exposing `async get_temperature() -> dict` returning `{"success": bool, "temperature_c": float, "setpoint_c": float, "state": str, ...}` (`gently/hardware/dispim/client.py:837`). +- Produces: + - `TemperatureSampler(Service)` with `__init__(self, microscope, store, session_id_getter, interval_sec=1.0)`. + - `async on_start(self)` / `async on_stop(self)` (background asyncio loop, like `DeviceStateMonitor`). + - `async _tick(self, bus) -> None` — one poll/append/emit cycle (the unit under test). + - attribute `self.latest: dict | None` — most recent sample (for the acquisition stamp). + - module function `temperature_stamp(latest: dict | None) -> dict | None` — `{"water_c","setpoint_c","state","sampled_at"}` or `None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler.py +import asyncio +from gently.core.event_bus import EventBus, EventType +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _run swallows; _tick raises — assert the loop-level guard swallows by calling _run-style guard: + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it (see test below) + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { + "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.app.temperature_sampler` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/app/temperature_sampler.py +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + while True: + try: + await self._tick(bus) + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + logger.warning("temperature sampler tick failed: %s", exc) + await asyncio.sleep(self._interval) + + async def _tick(self, bus) -> None: + session_id = self._session_id_getter() + if not session_id: + return + resp = await self._microscope.get_temperature() + if not resp or not resp.get("success", True): + return + water = resp.get("temperature_c") + if water is None: + return + sample = { + "t": _now_iso(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) +``` + +> Note the failure test: `_tick` propagates the poll exception, and `_run`'s `except Exception` swallows it. The test asserts the gap (no rows). If you prefer, also add a direct `_run`-guard test that starts the loop briefly and asserts it does not raise — optional. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/temperature_sampler.py tests/test_temperature_sampler.py +git commit -m "feat(temperature): TemperatureSampler service (poll/persist/emit @1Hz)" +``` + +--- + +### Task 4: Wire the sampler into the agent lifecycle + +**Files:** +- Modify: `gently/app/agent.py` — init attribute (near `:212`), construct+start (near the `DeviceStateMonitor` block `:818-827` inside `start_viz_server`), stop (near `:850-855`). +- Test: `tests/test_temperature_sampler_wiring.py` (new) + +**Interfaces:** +- Consumes: `TemperatureSampler` (Task 3); the agent's microscope client (`self.microscope`), its `FileStore`, and `self.session_id`. +- Produces: `agent.temperature_sampler: TemperatureSampler | None` (the live instance, read by the acquisition stamp in Task 6). + +> IMPLEMENTER: confirm the agent's FileStore attribute name before writing the construction line. Search `gently/app/agent.py` for the `FileStore` it uses (likely `self.store`). Use that exact attribute. If the agent reaches the store indirectly, pass whatever object exposes `append_temperature_sample`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler_wiring.py +from gently.app.temperature_sampler import TemperatureSampler + +def test_agent_initializes_temperature_sampler_attribute(): + # The attribute must exist (None until start_viz_server runs with a microscope). + import gently.app.agent as agent_mod + src = (agent_mod.__file__) + text = open(src, encoding="utf-8").read() + assert "temperature_sampler" in text + assert "TemperatureSampler(" in text +``` + +> This is a light wiring guard (a full agent boot is an integration concern). It fails until the wiring lines exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: FAIL — assertion error ("temperature_sampler" not in source) + +- [ ] **Step 3: Write minimal implementation** + +Near `:212` (with the other monitor attrs, e.g. `self.device_state_monitor = None`): + +```python + self.temperature_sampler = None +``` + +Inside `start_viz_server`, right after the `DeviceStateMonitor` start block (`:818-827`): + +```python + if self.microscope is not None and self.temperature_sampler is None: + from .temperature_sampler import TemperatureSampler + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() +``` + +In the symmetric shutdown path (`:850-855`, where `device_state_monitor.stop()` is awaited): + +```python + if self.temperature_sampler is not None: + await self.temperature_sampler.stop() + self.temperature_sampler = None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: PASS (1 passed) + +Then full suite sanity: `pytest -q` — Expected: no new failures. + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/agent.py tests/test_temperature_sampler_wiring.py +git commit -m "feat(temperature): start/stop TemperatureSampler with the agent" +``` + +--- + +### Task 5: History API route + +**Files:** +- Create: `gently/ui/web/routes/temperature.py` +- Modify: `gently/ui/web/routes/__init__.py` (import + add to the factories tuple in `register_all_routes`) +- Test: `tests/test_temperature_route.py` (new) +- Reference (template): `gently/ui/web/routes/experiments.py:1-66`, test template `tests/test_data_catalog.py:69-114` + +**Interfaces:** +- Consumes: `server.gently_store` (a `FileStore`) with `list_sessions()`, `_session_dir(id)`, and `read_temperature_log(id, since=)` (Task 1). +- Produces: `GET /api/temperature/{session_id}/history?since=` → `{"session_id": str, "samples": list[dict]}`; `session_id="current"` resolves to newest session. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_route.py +from unittest.mock import MagicMock +from pathlib import Path +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store.read_temperature_log.return_value = samples + srv = MagicMock(); srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI(); app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.ui.web.routes.temperature` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/ui/web/routes/temperature.py +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, since: str | None = None): + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router +``` + +Register it in `gently/ui/web/routes/__init__.py` — add the import and append `create_router` to the factories iterated by `register_all_routes` (follow the existing pattern exactly; alias to avoid name clashes, e.g. `from .temperature import create_router as create_temperature_router`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/temperature.py gently/ui/web/routes/__init__.py tests/test_temperature_route.py +git commit -m "feat(temperature): GET /api/temperature/{session}/history backfill route" +``` + +--- + +### Task 6: Acquisition temperature stamp (burst + volume) + +**Files:** +- Modify: `gently/app/orchestration/exclusive.py` — `_persist_burst_to_disk` (per-frame `metadata` at `:387-407`, `burst.yaml` manifest dict at `:423-442`); the `BurstAcquisition`/`ExclusiveAcquisition` construction to receive a temperature provider. +- Modify: the volume acquisition call site that builds `metadata` for `FileStore.put_volume`/`register_volume` (locate the caller in `gently/app/orchestration/timelapse.py` / `gently/app/tools/acquisition_tools.py`). +- Test: `tests/test_temperature_stamp.py` (new) — covers the pure helper + the volume metadata channel. +- Consumes: `temperature_stamp` and `agent.temperature_sampler.latest` (Tasks 3–4). + +**Interfaces:** +- Produces: a `temperature` block under `metadata` for volumes (`meta["metadata"]["temperature"]`) and under both per-frame `metadata` and the `burst.yaml` manifest top-level for bursts. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_stamp.py +import numpy as np +from gently.app.temperature_sampler import temperature_stamp + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(name="s") + emb = file_store.create_embryo(sid, position={"x": 0, "y": 0, "z": 0}) # confirm signature + stamp = temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + vol = np.zeros((2, 4, 4), dtype="uint16") + file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) + meta = file_store.get_volume_meta(sid, emb, 0) # confirm accessor name + assert meta["metadata"]["temperature"]["water_c"] == 28.4 +``` + +> IMPLEMENTER: confirm `create_embryo` and the volume-meta accessor (`get_volume_meta` or read the `.meta.yaml` directly via `get_volume_path(...).with_suffix` — adjust to the real API). The assertion target — `metadata["temperature"]` round-tripping into `t0000.meta.yaml` — is the contract; `put_volume` already nests the passed `metadata`, so this passes once the helper exists and the accessor is right. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: FAIL — initially on import/accessor; fix the accessor name from the real API, then it exercises the channel. + +- [ ] **Step 3: Write minimal implementation** + +**Volume path** — at the acquisition call site that calls `put_volume`/`register_volume`, fold the stamp into the existing `metadata` dict: + +```python +from gently.app.temperature_sampler import temperature_stamp +# ... where `agent` (or self) holds the sampler and `metadata` is being built: +stamp = temperature_stamp(getattr(getattr(agent, "temperature_sampler", None), "latest", None)) +if stamp is not None: + metadata["temperature"] = stamp +``` + +**Burst path** — `gently/app/orchestration/exclusive.py`: +1. Add a constructor param to the acquisition class that persists bursts: `temperature_provider=None` (a zero-arg callable returning the latest sample dict, or `None`), stored as `self._temperature_provider`. +2. In `_persist_burst_to_disk`, compute once: + +```python +from gently.app.temperature_sampler import temperature_stamp +_temp = temperature_stamp(self._temperature_provider() if self._temperature_provider else None) +``` + +3. Inject into the per-frame `metadata` dict (`:387-407`): add `"temperature": _temp` (only when not None — or always; `None` is acceptable YAML). +4. Inject into the `burst.yaml` manifest dict (`:423-442`): add a top-level `"temperature": _temp`. +5. Where this acquisition class is constructed (the orchestrator that owns bursts — `gently/app/orchestration/timelapse.py`), pass `temperature_provider=lambda: agent.temperature_sampler.latest if agent.temperature_sampler else None` (use the orchestrator's existing agent/store handle; confirm the attribute). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: PASS + +- [ ] **Step 5: Verify burst wiring by inspection + targeted run** + +The burst persist writes real TIFFs, so it is verified by inspection (the `_temp` block is added in both dict sites) plus, during end-to-end verification (after Task 7), trigger one burst against the mock device and confirm `burst.yaml` and a frame `.meta.yaml` contain a `temperature` block. Note in the commit if the volume call site could not be located and was deferred — do not silently skip it. + +- [ ] **Step 6: Commit** + +```bash +git add gently/app/orchestration/exclusive.py gently/app/orchestration/timelapse.py tests/test_temperature_stamp.py +git commit -m "feat(temperature): stamp latest reading into burst + volume metadata" +``` + +--- + +### Task 7: Frontend — temperature graph component + Devices card + +**Files:** +- Create: `gently/ui/web/static/js/temperature-graph.js` +- Modify: `gently/ui/web/templates/index.html` — add a chart container inside `#devices-content` (the existing small readout is at `:452-465`; mount the chart as a section under the Details/Map view). +- Modify: `gently/ui/web/static/js/devices.js` — initialize the chart in `init()` (`:1556`) and subscribe it to `TEMPERATURE_UPDATE` (next to the `DEVICE_STATE_UPDATE` subscription at `:1561`). +- Reference (SVG style): `gently/ui/web/static/js/experiment-overview.js`; (event API) `gently/ui/web/static/js/event-bus.js:9-48` (`ClientEventBus.on(type, handler)`). + +**No JS unit harness exists** — this task is verified by running the app + Chrome DevTools MCP (see Step 4), consistent with the repo and the "UI audit before done" practice. + +- [ ] **Step 1: Build the component** + +Create `temperature-graph.js` exposing a small object/module: + +```javascript +// gently/ui/web/static/js/temperature-graph.js +// Hand-rolled SVG line chart: water-temp trace + stepped setpoint line. +// No dependency. Backfills from /api/temperature/{session}/history, then appends +// from TEMPERATURE_UPDATE events. Calm empty state, never mock data. +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + let _root = null, _samples = [], _session = "current"; + + function init(container, sessionId) { + _root = container; _session = sessionId || "current"; _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { renderEmpty(); } + } + + function onEvent(data) { + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + _root.innerHTML = '
    No temperature data yet
    '; + } + + function render() { + if (!_samples.length) { renderEmpty(); return; } + const W = _root.clientWidth || 480, H = 160, pad = 24; + const xs = _samples.map((_, i) => i); + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + const lo = Math.min(...ws, ...sps) - 1, hi = Math.max(...ws, ...sps) + 1; + const sx = i => pad + (i / Math.max(1, xs.length - 1)) * (W - 2 * pad); + const sy = v => H - pad - ((v - lo) / Math.max(0.001, hi - lo)) * (H - 2 * pad); + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", "100%"); + + const line = (pts, cls) => { + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts); p.setAttribute("class", cls); + p.setAttribute("fill", "none"); svg.appendChild(p); + }; + line(_samples.map((s, i) => s.water_c != null ? `${sx(i)},${sy(s.water_c)}` : "").filter(Boolean).join(" "), "temp-water"); + // Stepped setpoint: carry previous y until it changes. + let sp = []; _samples.forEach((s, i) => { if (s.setpoint_c != null) sp.push(`${sx(i)},${sy(s.setpoint_c)}`); }); + line(sp.join(" "), "temp-setpoint"); + + const last = _samples[_samples.length - 1]; + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.textContent = `${last.water_c?.toFixed?.(1) ?? "—"} °C → ${last.setpoint_c?.toFixed?.(1) ?? "—"} °C (${last.state ?? ""})`; + + _root.innerHTML = ""; _root.appendChild(readout); _root.appendChild(svg); + } + + function dispose() { ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); } + return { init, dispose, _render: render, _samples: () => _samples }; +})(); +window.TemperatureGraph = TemperatureGraph; +``` + +Add minimal CSS (in the devices stylesheet) for `.temp-water` (stroke: water color), `.temp-setpoint` (dashed stroke), `.temp-graph-empty` (muted), matching existing palette. + +- [ ] **Step 2: Mount it** + +In `templates/index.html`, add inside `#devices-content` (under the map/details view): + +```html +
    +``` + +Load the script (next to the other `static/js/*.js` includes for the devices tab). + +In `devices.js` `init()` (`:1556`), after existing setup: + +```javascript + const tg = document.getElementById('devices-temp-graph'); + if (tg && window.TemperatureGraph) TemperatureGraph.init(tg, 'current'); +``` + +(No extra subscription needed in `devices.js` — the component self-subscribes. Optionally also route the existing small readout off the new event.) + +- [ ] **Step 3: Add the script tag** + +Add `` in `index.html` alongside the other component scripts, **before** `devices.js` loads (so `window.TemperatureGraph` exists when `init()` runs). + +- [ ] **Step 4: Verify live in the app (Chrome DevTools MCP)** + +Use the `run` skill to launch the app with the mock temperature backend and an active session. Then with Chrome DevTools MCP: +- navigate to the Devices tab, take a snapshot/screenshot; +- confirm the empty state shows when no samples, then the water trace + stepped setpoint render and update live as the sampler emits; +- run the UI audit (alignment/spacing/overflow/contrast) per the "UI audit before done" practice and fix any flaws; +- trigger one burst and confirm (Task 6) that `burst.yaml` + a frame `.meta.yaml` carry a `temperature` block. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/static/js/temperature-graph.js gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js +git commit -m "feat(temperature): live SVG temperature graph on the Devices tab" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Persistence / `temperature.jsonl` → Task 1. ✓ +- Sampler (poll @1 Hz, session-gated, latest-in-memory, SSE) → Tasks 2–4. ✓ +- Per-acquisition stamp (burst + volume) → Task 6. ✓ +- History API (`current` resolution, `since`) → Task 5. ✓ +- Reusable SVG graph on Devices card (water trace + stepped setpoint + readout, backfill + live, empty state) → Task 7. ✓ +- Error/empty handling (gap-not-crash, no-device idle, empty state) → Tasks 3 & 7. ✓ +- Out-of-scope (setpoint control, choreography, always-on) → not implemented, by design. ✓ + +**Open verification items folded into tasks (not placeholders):** session-creation API (Task 1/3/6), `EventBus.subscribe` shape (Task 2), the agent's FileStore attribute (Task 4), the volume-meta accessor + `create_embryo` signature (Task 6), the burst-acquisition construction site (Task 6). Each is an explicit "confirm from the real API" instruction with a concrete fallback, not a TODO. + +**Type consistency:** `temperature_stamp` returns `{water_c, setpoint_c, state, sampled_at}` everywhere; sample lines are `{t, water_c, setpoint_c, state}`; event payload is `{session_id, sample}`; history response is `{session_id, samples}`. Consistent across Tasks 1/3/5/6/7. From 06a5883eb38dc57f4e627b5dff12ac0d8c1ab43d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 21:46:05 +0530 Subject: [PATCH 072/200] feat(temperature): session-scoped temperature.jsonl store on FileStore --- gently/core/file_store.py | 15 +++++++++++++++ tests/test_temperature_store.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/test_temperature_store.py diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 5a8b7f01..467d0c5e 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -444,6 +444,21 @@ def load_session_snapshot(self, session_id: str) -> dict | None: with open(path, encoding="utf-8") as f: return json.load(f) + def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _append_jsonl(sd / "temperature.jsonl", sample) + + def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: + """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).""" + sd = self._session_dir(session_id) + if sd is None: + return [] + rows = _read_jsonl(sd / "temperature.jsonl") + if since is not None: + rows = [r for r in rows if str(r.get("t", "")) >= since] + return rows + # ------------------------------------------------------------------ # Session lock # ------------------------------------------------------------------ diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py new file mode 100644 index 00000000..b51da792 --- /dev/null +++ b/tests/test_temperature_store.py @@ -0,0 +1,34 @@ +"""Tests for FileStore temperature log methods.""" + +import pytest + + +def _new_session(file_store): + """Create a test session and return its ID.""" + # Use UUID-based session ID for uniqueness + import uuid + session_id = str(uuid.uuid4()) + return file_store.create_session(session_id, name="temp-test") + + +def test_append_and_read_roundtrip(file_store): + """Test appending and reading temperature samples.""" + sid = _new_session(file_store) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + + +def test_read_since_filters(file_store): + """Test that read_temperature_log filters by since parameter.""" + sid = _new_session(file_store) + for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): + file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + + +def test_read_unknown_session_is_empty(file_store): + """Test that reading from a non-existent session returns empty list.""" + assert file_store.read_temperature_log("does-not-exist") == [] From 3279c8f77e2d0ea3b7ecfb6d24619cc146256359 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 21:49:28 +0530 Subject: [PATCH 073/200] feat(temperature): add TEMPERATURE_UPDATE event type --- gently/core/event_bus.py | 2 ++ tests/test_temperature_event.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/test_temperature_event.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index ce3aac03..b3bd7e68 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -84,6 +84,7 @@ class EventType(Enum): FOCUS_CHANGED = auto() LASER_CHANGED = auto() DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer + TEMPERATURE_UPDATE = auto() # Temperature reading from device layer BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view @@ -185,6 +186,7 @@ class EventType(Enum): _NO_HISTORY_TYPES = frozenset( { EventType.DEVICE_STATE_UPDATE, + EventType.TEMPERATURE_UPDATE, # High-volume telemetry from temperature controller EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out EventType.LOG_RECORD, # log lines can hit hundreds/min during # calibration; durable copy is in the diff --git a/tests/test_temperature_event.py b/tests/test_temperature_event.py new file mode 100644 index 00000000..388976aa --- /dev/null +++ b/tests/test_temperature_event.py @@ -0,0 +1,21 @@ +""" +Test suite for TEMPERATURE_UPDATE event type +""" + +from gently.core.event_bus import EventType, EventBus + + +def test_temperature_update_event_exists(): + """Verify TEMPERATURE_UPDATE enum member exists with correct name""" + assert hasattr(EventType, 'TEMPERATURE_UPDATE') + event_type = EventType.TEMPERATURE_UPDATE + assert event_type.name == "TEMPERATURE_UPDATE" + + +def test_temperature_update_publishes_to_subscriber(): + """Verify publishing TEMPERATURE_UPDATE reaches subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] From 81bba23ae739dfaae830ae236350edc055c67e5c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 21:54:22 +0530 Subject: [PATCH 074/200] feat(temperature): TemperatureSampler service (poll/persist/emit @1Hz) --- gently/app/temperature_sampler.py | 92 +++++++++++++++++++++++++++++++ tests/test_temperature_sampler.py | 73 ++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 gently/app/temperature_sampler.py create mode 100644 tests/test_temperature_sampler.py diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py new file mode 100644 index 00000000..5ccec94d --- /dev/null +++ b/gently/app/temperature_sampler.py @@ -0,0 +1,92 @@ +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + while True: + try: + await self._tick(bus) + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + logger.warning("temperature sampler tick failed: %s", exc) + await asyncio.sleep(self._interval) + + async def _tick(self, bus) -> None: + session_id = self._session_id_getter() + if not session_id: + return + resp = await self._microscope.get_temperature() + if not resp or not resp.get("success", True): + return + water = resp.get("temperature_c") + if water is None: + return + sample = { + "t": _now_iso(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py new file mode 100644 index 00000000..3e208069 --- /dev/null +++ b/tests/test_temperature_sampler.py @@ -0,0 +1,73 @@ +"""Tests for TemperatureSampler service. + +Adaptation note: create_session() requires session_id as its first positional +argument (confirmed from file_store.py:328). The brief's +`file_store.create_session(name="s")` is adapted to +`file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API. +""" +import asyncio +import uuid + +from gently.core.event_bus import EventBus, EventType +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _tick propagates the poll exception; _run's except-Exception swallows it. + # Assert the gap (no rows). + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { + "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + } From 122a5804a94ff4223dc8c01ee61c3185dfe96e06 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:00:04 +0530 Subject: [PATCH 075/200] feat(temperature): start/stop TemperatureSampler with the agent --- gently/app/agent.py | 21 +++++++++++++++++ tests/test_temperature_sampler_wiring.py | 30 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/test_temperature_sampler_wiring.py diff --git a/gently/app/agent.py b/gently/app/agent.py index f68d67a7..3794f141 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -210,6 +210,8 @@ def __init__( # Device-state monitor (bridges device-layer SSE → EventBus) self.device_state_monitor = None + # Session-scoped temperature sampler — polls device layer, persists readings. + self.temperature_sampler = None # Opt-in bottom-camera stream bridge — created when viz starts, but # left unstarted until the operator clicks "Start camera" in the UI. self.bottom_camera_monitor = None @@ -826,6 +828,19 @@ async def start_viz_server( logger.warning(f"Failed to start device-state monitor: {e}") self.device_state_monitor = None + if self.microscope is not None and self.temperature_sampler is None: + try: + from .temperature_sampler import TemperatureSampler + + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() + logger.info("Temperature sampler started") + except Exception as e: + logger.warning(f"Failed to start temperature sampler: {e}") + self.temperature_sampler = None + # Construct the bottom-camera monitor — but don't start it. Streaming # is opt-in and waits for an explicit operator action via the # /api/devices/bottom_camera/stream/start route. @@ -853,6 +868,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop device-state monitor") self.device_state_monitor = None + if self.temperature_sampler is not None: + try: + await self.temperature_sampler.stop() + except Exception: + logger.exception("Failed to stop temperature sampler") + self.temperature_sampler = None if self.viz_server is not None: await self.viz_server.stop() self.viz_server = None diff --git a/tests/test_temperature_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py new file mode 100644 index 00000000..9564d775 --- /dev/null +++ b/tests/test_temperature_sampler_wiring.py @@ -0,0 +1,30 @@ +"""Wiring guard: asserts that agent.py contains the TemperatureSampler lifecycle hooks. + +This is an intentionally lightweight source-text guard — a full agent boot is a +heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's +own wiring is likewise not unit-tested at this level. +""" +from gently.app.temperature_sampler import TemperatureSampler + + +def test_agent_initializes_temperature_sampler_attribute(): + """agent.py must declare the attribute *and* construct the sampler. + + Uses find_spec to locate the source file without executing the module + (agent.py has heavy runtime deps like anthropic that aren't present in the + test environment). + """ + import importlib.util + from pathlib import Path + + spec = importlib.util.find_spec("gently.app.agent") + assert spec is not None and spec.origin is not None, ( + "Could not locate gently/app/agent.py via importlib" + ) + text = Path(spec.origin).read_text(encoding="utf-8") + assert "temperature_sampler" in text, ( + "agent.py has no 'temperature_sampler' attribute" + ) + assert "TemperatureSampler(" in text, ( + "agent.py never constructs a TemperatureSampler" + ) From 1a062c299b047628d69f475c1e64dbf565f1b75f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:05:38 +0530 Subject: [PATCH 076/200] feat(temperature): GET /api/temperature/{session}/history backfill route --- gently/ui/web/routes/__init__.py | 2 ++ gently/ui/web/routes/temperature.py | 46 +++++++++++++++++++++++++++ tests/test_temperature_route.py | 49 +++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 gently/ui/web/routes/temperature.py create mode 100644 tests/test_temperature_route.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 9f8cd903..66e8842c 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,6 +13,7 @@ from .context import create_router as create_context_router from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router +from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router from .notebook import create_router as create_notebook_router from .pages import create_router as create_pages_router @@ -37,6 +38,7 @@ def register_all_routes(server): create_chat_router, create_context_router, create_notebook_router, + create_temperature_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py new file mode 100644 index 00000000..1505714b --- /dev/null +++ b/gently/ui/web/routes/temperature.py @@ -0,0 +1,46 @@ +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" +import urllib.parse + +from fastapi import APIRouter, HTTPException, Request + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, request: Request): + # Parse `since` from raw query string using unquote (not unquote_plus) so that + # timezone offsets like +00:00 are preserved. Standard FastAPI query-param + # parsing applies unquote_plus, which converts + to a space. + raw_qs = request.scope.get("query_string", b"").decode() + since = None + for part in raw_qs.split("&"): + if "=" in part: + k, v = part.split("=", 1) + if urllib.parse.unquote(k) == "since": + since = urllib.parse.unquote(v) + break + + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py new file mode 100644 index 00000000..4cd02cf3 --- /dev/null +++ b/tests/test_temperature_route.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock +from pathlib import Path +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store.read_temperature_log.return_value = samples + srv = MagicMock(); srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI(); app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 From a8e9817f61b1c4c1783a2160aca3e469068aad7b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:18:23 +0530 Subject: [PATCH 077/200] feat(temperature): stamp latest reading into burst + volume metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BurstAcquisition accepts temperature_provider (zero-arg callable); _persist_burst_to_disk injects temperature_stamp() into per-frame metadata["metadata"]["temperature"] and burst.yaml top-level "temperature" - TimelapseOrchestrator accepts temperature_provider and threads it through queue_burst() and _apply_runtime_state() → BurstAcquisition - agent.py passes lambda: temperature_sampler.latest when constructing the orchestrator so live sessions stamp real readings - acquisition_tools: folds temperature_stamp into acq_metadata on every put_volume / register_volume call (volume path) - FileStore.get_volume_meta() accessor added (reads tNNNN.meta.yaml) - tests/test_temperature_stamp.py: TDD — None-guard + volume round-trip --- gently/app/agent.py | 5 +++ gently/app/orchestration/exclusive.py | 13 +++++++ gently/app/orchestration/timelapse.py | 8 ++++ gently/app/tools/acquisition_tools.py | 8 ++++ gently/core/file_store.py | 10 +++++ tests/test_temperature_stamp.py | 54 +++++++++++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 tests/test_temperature_stamp.py diff --git a/gently/app/agent.py b/gently/app/agent.py index 3794f141..afe2e649 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -635,6 +635,11 @@ def _init_timelapse_orchestrator(self): session_id=self.session_id, store=self.store, claude_client=self.claude, + temperature_provider=lambda: ( + self.temperature_sampler.latest + if self.temperature_sampler + else None + ), ) except Exception as e: logging.getLogger(__name__).warning(f"Failed to init timelapse orchestrator: {e}") diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index db9804bd..83b196d7 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -23,6 +23,8 @@ import numpy as np +from gently.app.temperature_sampler import temperature_stamp + logger = logging.getLogger(__name__) @@ -98,11 +100,13 @@ def __init__( mode: str = "1hz", num_slices: int = 1, request_id: str | None = None, + temperature_provider=None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames self.mode = mode if mode in ("1hz", "asap") else "1hz" self.num_slices = num_slices + self._temperature_provider = temperature_provider async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -204,6 +208,7 @@ async def run(self, orchestrator) -> ExclusiveResult: piezo_amplitude=piezo_amplitude, piezo_center=piezo_center, laser_power_488_pct=getattr(embryo, "laser_power_488_pct", None), + temperature_provider=self._temperature_provider, ) # Generate MP4 (derivative artifact; safe to fail). @@ -318,6 +323,7 @@ def _persist_burst_to_disk( piezo_amplitude: float, piezo_center: float, laser_power_488_pct: float | None, + temperature_provider=None, ) -> Path | None: """Save per-frame TIFFs + meta + projections + a burst.yaml manifest. @@ -344,6 +350,11 @@ def _persist_burst_to_disk( proj_dir = burst_dir / "projections" proj_dir.mkdir(exist_ok=True) + # Compute temperature stamp once for the whole burst (all frames share the + # same reading — the sampler captures at ~1 Hz so per-frame variation is + # sub-resolution anyway). + _temp = temperature_stamp(temperature_provider() if temperature_provider else None) + # Position recorded for the manifest. pos = getattr(embryo, "stage_position", {}) or {} sid = getattr(orchestrator, "_session_id", None) @@ -403,6 +414,7 @@ def _persist_burst_to_disk( "burst_mode": mode, "laser_power_488_pct": laser_power_488_pct, "role": "burst", + "temperature": _temp, }, } if _yaml is not None: @@ -432,6 +444,7 @@ def _persist_burst_to_disk( "sustained_hz": sustained_hz, "embryo_position": {"x": pos.get("x"), "y": pos.get("y")}, "laser_power_488_pct": laser_power_488_pct, + "temperature": _temp, "scan": { "galvo_amplitude": galvo_amplitude, "galvo_center": galvo_center, diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index 6b140922..5918bd9c 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -74,6 +74,7 @@ def __init__( session_id: str | None = None, store: Optional["FileStore"] = None, claude_client=None, + temperature_provider=None, ): """ Parameters @@ -102,6 +103,11 @@ def __init__( self.on_volume_callback = on_volume_callback self.claude_client = claude_client + # Zero-arg callable returning the latest temperature sample dict (or None). + # Threaded from the agent's TemperatureSampler so burst frames carry a + # temperature block in their metadata. + self._temperature_provider = temperature_provider + # Trace file storage (writes JSON files to disk) self._session_id = session_id self._trace_dir: Path | None = None @@ -1974,6 +1980,7 @@ def _parse_dt(s): mode=op_doc.get("mode", "1hz"), num_slices=int(op_doc.get("num_slices", 1)), request_id=op_doc.get("request_id"), + temperature_provider=self._temperature_provider, ) ) @@ -2092,6 +2099,7 @@ def queue_burst( frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=self._temperature_provider, ) self._exclusive_queue.append(op) logger.info( diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 79f69732..6e029ad4 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -212,6 +212,14 @@ async def acquire_volume( "piezo_center": piezo_center, }, } + from gently.app.temperature_sampler import temperature_stamp as _ts + _temp_stamp = _ts( + getattr( + getattr(agent, "temperature_sampler", None), "latest", None + ) + ) + if _temp_stamp is not None: + acq_metadata["temperature"] = _temp_stamp volume_path_ref = result.get("volume_path") if volume_path_ref is not None: saved_path = agent.store.register_volume( diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 467d0c5e..ae668232 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -750,6 +750,16 @@ def get_volume_path(self, session_id: str, embryo_id: str, timepoint: int) -> Pa return vol_path return None + def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> dict | None: + """Read the sidecar metadata YAML for a volume. Returns None if not found.""" + sd = self._session_dir(session_id) + if sd is None: + return None + meta_path = ( + sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) + ) + return _read_yaml(meta_path) + def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]: """List volume metadata by scanning sidecar YAML files on disk.""" sd = self._session_dir(session_id) diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py new file mode 100644 index 00000000..57dfb603 --- /dev/null +++ b/tests/test_temperature_stamp.py @@ -0,0 +1,54 @@ +"""Tests for Task 6: acquisition temperature stamp. + +Two concerns: +1. temperature_stamp(None) returns None (pure helper, no I/O). +2. Volume metadata round-trip: a stamp injected via put_volume(metadata={"temperature": + stamp}) lands correctly in the sidecar YAML and is readable via get_volume_meta. + +Confirmed from file_store.py: +- create_session(session_id, name=None, ...) — session_id is the first positional arg. +- register_embryo(session_id, embryo_id, ...) — embryo_id is the key; returns None. +- put_volume(session_id, embryo_id, timepoint, volume, metadata=None) — nests `metadata` + under the "metadata" key in the sidecar YAML. +- get_volume_meta(session_id, embryo_id, timepoint) — added by Task 6; reads sidecar YAML. + +Note: tifffile is not installed in the test environment; it is mocked so put_volume +writes only the sidecar YAML (the contract under test) without the TIFF. +""" + +import sys +import uuid +from unittest.mock import MagicMock, patch + +import numpy as np + +from gently.app.temperature_sampler import temperature_stamp + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + emb = "embryo_1" + file_store.register_embryo(sid, emb, position_x=0.0, position_y=0.0) + stamp = temperature_stamp( + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + } + ) + vol = np.zeros((2, 4, 4), dtype="uint16") + + # tifffile is not installed in this environment; mock it so put_volume can + # proceed to writing the sidecar YAML (the part under test). + tifffile_mock = MagicMock() + with patch.dict(sys.modules, {"tifffile": tifffile_mock}): + with patch.object(file_store, "_generate_projection", return_value=None): + file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) + + meta = file_store.get_volume_meta(sid, emb, 0) + assert meta["metadata"]["temperature"]["water_c"] == 28.4 From e21b567d07e22b1c00780b53eae8485b235c3012 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:27:20 +0530 Subject: [PATCH 078/200] feat(temperature): live SVG temperature graph on the Devices tab --- gently/ui/web/static/css/main.css | 57 +++++++ gently/ui/web/static/js/devices.js | 2 + gently/ui/web/static/js/temperature-graph.js | 167 +++++++++++++++++++ gently/ui/web/templates/index.html | 4 + 4 files changed, 230 insertions(+) create mode 100644 gently/ui/web/static/js/temperature-graph.js diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 10bc6f62..cf878140 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9736,6 +9736,63 @@ body.modal-open { .devices-temp-set:hover:not(:disabled) { border-color: var(--map-accent); color: var(--map-ink); } .devices-temp-set:disabled { opacity: 0.5; cursor: default; } +/* --- Temperature trajectory graph ------------------------------------- */ +/* SVG chart card mounted at the bottom of #devices-content. */ +.devices-temp-graph { + padding: 0.6rem 1rem 0.5rem; + border-top: 1px solid var(--map-overlay-edge); + min-height: 2rem; /* keeps the section visible even when empty */ +} +/* Solid line: water temperature */ +.temp-water { + stroke: var(--map-accent); + stroke-width: 1.5; + stroke-linejoin: round; + stroke-linecap: round; +} +/* Dashed line: setpoint (stepped) */ +.temp-setpoint { + stroke: #f59e0b; + stroke-width: 1.5; + stroke-dasharray: 5 3; + stroke-linejoin: round; + stroke-linecap: round; + opacity: 0.85; +} +/* Y-axis reference gridlines */ +.temp-grid-line { + stroke: var(--map-ink-faint); + stroke-width: 0.75; +} +/* Y-axis tick labels */ +.temp-grid-label { + fill: var(--map-ink-mute); + font-size: 8px; + font-family: ui-monospace, monospace; + text-anchor: end; + dominant-baseline: middle; +} +/* Calm empty state — never shows mock data */ +.temp-graph-empty { + padding: 1rem 0; + text-align: center; + color: var(--map-ink-mute); + font-size: 0.78rem; +} +/* Last-sample readout above the SVG */ +.temp-graph-readout { + font-size: 0.68rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + color: var(--map-ink-mute); + margin-bottom: 0.2rem; + padding: 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* --- Containers ------------------------------------------------------- */ .devices-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .devices-view-details { gap: 1rem; } diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 040b11d1..a56f0015 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -1554,6 +1554,8 @@ const DevicesManager = (function () { setupCameraWiring(); setupRoomLight(); setupTemperature(); + const _tgEl = document.getElementById('devices-temp-graph'); + if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current'); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js new file mode 100644 index 00000000..4679c970 --- /dev/null +++ b/gently/ui/web/static/js/temperature-graph.js @@ -0,0 +1,167 @@ +/** + * Temperature Graph — hand-rolled SVG line chart for the Devices tab. + * + * Shows the water-temp trace (solid) and stepped setpoint line (dashed), + * backfilled from /api/temperature/{session}/history and then updated live + * via TEMPERATURE_UPDATE events from the ClientEventBus. + * + * No external dependencies. Calm empty state; never renders mock data. + * + * Usage: + * TemperatureGraph.init(containerEl, 'current'); + * // The component self-subscribes; no extra subscription needed in the caller. + * TemperatureGraph.dispose(); // on teardown + */ +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + + let _root = null; + let _samples = []; + let _session = "current"; + + function init(container, sessionId) { + _root = container; + _session = sessionId || "current"; + _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + // Adopt the resolved session_id (e.g. 'current' → real id) so that + // subsequent event filtering is consistent. + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { + console.warn('[TemperatureGraph] backfill error:', e); + renderEmpty(); + } + } + + function onEvent(data) { + // data = {session_id, sample: {t, water_c, setpoint_c, state}} + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + if (!_root) return; + _root.innerHTML = '
    No temperature data yet
    '; + } + + function render() { + if (!_root) return; + if (!_samples.length) { renderEmpty(); return; } + + const W = _root.clientWidth || 480; + const H = 160; + const pad = { top: 12, right: 16, bottom: 20, left: 28 }; + + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + + if (!ws.length) { renderEmpty(); return; } + + const allVals = [...ws, ...sps]; + const lo = Math.min(...allVals) - 0.5; + const hi = Math.max(...allVals) + 0.5; + const range = Math.max(0.001, hi - lo); + const plotW = W - pad.left - pad.right; + const plotH = H - pad.top - pad.bottom; + const n = _samples.length; + + const sx = i => pad.left + (i / Math.max(1, n - 1)) * plotW; + const sy = v => pad.top + plotH - ((v - lo) / range) * plotH; + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.setAttribute("width", "100%"); + svg.setAttribute("aria-hidden", "true"); + + // Y-axis gridlines (3 levels) + const gridG = document.createElementNS(SVGNS, "g"); + gridG.setAttribute("class", "temp-graph-grid"); + for (let k = 0; k <= 2; k++) { + const v = lo + (k / 2) * (hi - lo); + const y = sy(v); + const gridLine = document.createElementNS(SVGNS, "line"); + gridLine.setAttribute("x1", pad.left); + gridLine.setAttribute("x2", W - pad.right); + gridLine.setAttribute("y1", y); + gridLine.setAttribute("y2", y); + gridLine.setAttribute("class", "temp-grid-line"); + gridG.appendChild(gridLine); + + const lbl = document.createElementNS(SVGNS, "text"); + lbl.setAttribute("x", pad.left - 3); + lbl.setAttribute("y", y + 3.5); + lbl.setAttribute("class", "temp-grid-label"); + lbl.textContent = v.toFixed(1); + gridG.appendChild(lbl); + } + svg.appendChild(gridG); + + // Helper: build a polyline from a point-string array + const makeLine = (pts, cls) => { + if (!pts.length) return; + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts.join(" ")); + p.setAttribute("class", cls); + p.setAttribute("fill", "none"); + svg.appendChild(p); + }; + + // Water temp — skip null samples (gap rather than crash) + const waterPts = []; + _samples.forEach((s, i) => { + if (s.water_c != null) waterPts.push(`${sx(i).toFixed(1)},${sy(s.water_c).toFixed(1)}`); + }); + makeLine(waterPts, "temp-water"); + + // Setpoint — stepped: carry the last known setpoint forward + const spPts = []; + let lastSP = null; + _samples.forEach((s, i) => { + const sp = s.setpoint_c != null ? s.setpoint_c : lastSP; + if (sp != null) { + spPts.push(`${sx(i).toFixed(1)},${sy(sp).toFixed(1)}`); + lastSP = sp; + } + }); + makeLine(spPts, "temp-setpoint"); + + // Current readout line (last sample) + const last = _samples[_samples.length - 1]; + const wStr = last.water_c != null ? `${last.water_c.toFixed(1)} °C` : "—"; + const spStr = last.setpoint_c != null ? `${last.setpoint_c.toFixed(1)} °C` : "—"; + const stStr = last.state ? ` · ${last.state}` : ""; + + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.title = "Water temperature → setpoint (state)"; + readout.textContent = `${wStr} → ${spStr}${stStr}`; + + _root.innerHTML = ""; + _root.appendChild(readout); + _root.appendChild(svg); + } + + function dispose() { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); + _root = null; + _samples = []; + } + + // Exposed for testing / forced refresh from devices.js + return { init, dispose, _render: render, _samples: () => _samples }; +})(); + +window.TemperatureGraph = TemperatureGraph; diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 8bc8bf06..7e1f0726 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -711,6 +711,9 @@

    Properties

    + + +
    @@ -807,6 +810,7 @@

    Properties

    + From 0daebda8dd22d55d19c72a7ea2881603e699c5b1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:35:08 +0530 Subject: [PATCH 079/200] fix(temperature): idempotent graph init + theme setpoint color + label baseline --- gently/ui/web/static/css/main.css | 8 +++++++- gently/ui/web/static/js/temperature-graph.js | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index cf878140..0b115fe6 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,6 +23,9 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; + /* Temperature graph colors */ + --temp-setpoint-color: #f59e0b; + /* Gradients for modern feel */ --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #c084fc 100%); --gradient-success: linear-gradient(135deg, #4ade80 0%, #22d3ee 100%); @@ -63,6 +66,9 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; + /* Temperature graph colors */ + --temp-setpoint-color: #f59e0b; + /* Gradients */ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%); --gradient-success: linear-gradient(135deg, #22c55e 0%, #06b6d4 100%); @@ -9752,7 +9758,7 @@ body.modal-open { } /* Dashed line: setpoint (stepped) */ .temp-setpoint { - stroke: #f59e0b; + stroke: var(--temp-setpoint-color); stroke-width: 1.5; stroke-dasharray: 5 3; stroke-linejoin: round; diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js index 4679c970..86129902 100644 --- a/gently/ui/web/static/js/temperature-graph.js +++ b/gently/ui/web/static/js/temperature-graph.js @@ -21,6 +21,7 @@ const TemperatureGraph = (() => { let _session = "current"; function init(container, sessionId) { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); _root = container; _session = sessionId || "current"; _samples = []; @@ -102,7 +103,7 @@ const TemperatureGraph = (() => { const lbl = document.createElementNS(SVGNS, "text"); lbl.setAttribute("x", pad.left - 3); - lbl.setAttribute("y", y + 3.5); + lbl.setAttribute("y", y); lbl.setAttribute("class", "temp-grid-label"); lbl.textContent = v.toFixed(1); gridG.appendChild(lbl); From 1f6c7fa63d0ab1fe0f39feabf2b72e31e7ad2371 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:35:08 +0530 Subject: [PATCH 080/200] docs(temperature): correct EventType auto() example in plan --- docs/superpowers/plans/2026-06-27-temperature-interface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md index ab02b895..cfdaa563 100644 --- a/docs/superpowers/plans/2026-06-27-temperature-interface.md +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -134,10 +134,10 @@ Expected: FAIL — `AttributeError: TEMPERATURE_UPDATE` - [ ] **Step 3: Write minimal implementation** -In `gently/core/event_bus.py`, add the member alongside the other `EventType` values (mirror how `DEVICE_STATE_UPDATE` at `:86` is declared — match the existing value style, e.g. `TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE"`): +In `gently/core/event_bus.py`, add the member alongside the other `EventType` values, **matching the existing `auto()` style** (`DEVICE_STATE_UPDATE = auto()` at `:86`). The wire protocol serializes `event.event_type.name` (`Event.to_dict` at event_bus.py:232; server.py:377/419), so the browser receives the string `"TEMPERATURE_UPDATE"` regardless — which is what the frontend (Task 7) subscribes to. The test must assert `.name == "TEMPERATURE_UPDATE"` (NOT `.value`, which is an `auto()` int): ```python - TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE" + TEMPERATURE_UPDATE = auto() # high-volume telemetry from the temperature controller ``` And add it to the high-volume set so it is not retained in history (next to `DEVICE_STATE_UPDATE` in `_NO_HISTORY_TYPES` near `:187`): From 60e63dd2380b3f11a597e46ea6f9825c6f6c10a6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:46:49 +0530 Subject: [PATCH 081/200] fix(temperature): events-tab exclusion, burst-stamp test, invalidate stale latest, tolerant log read --- gently/app/temperature_sampler.py | 3 ++ gently/core/file_store.py | 20 +++++++- gently/ui/web/static/js/websocket.js | 3 +- tests/test_temperature_sampler.py | 34 +++++++++++++ tests/test_temperature_stamp.py | 72 ++++++++++++++++++++++++++-- tests/test_temperature_store.py | 24 ++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py index 5ccec94d..e762534c 100644 --- a/gently/app/temperature_sampler.py +++ b/gently/app/temperature_sampler.py @@ -70,12 +70,15 @@ async def _run(self) -> None: async def _tick(self, bus) -> None: session_id = self._session_id_getter() if not session_id: + self.latest = None return resp = await self._microscope.get_temperature() if not resp or not resp.get("success", True): + self.latest = None return water = resp.get("temperature_c") if water is None: + self.latest = None return sample = { "t": _now_iso(), diff --git a/gently/core/file_store.py b/gently/core/file_store.py index ae668232..e7fb2174 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -450,11 +450,27 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None: _append_jsonl(sd / "temperature.jsonl", sample) def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: - """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).""" + """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string). + + Reads lines tolerantly: a truncated trailing line (e.g. after a mid-append + crash) is silently skipped rather than raising a JSONDecodeError. + """ sd = self._session_dir(session_id) if sd is None: return [] - rows = _read_jsonl(sd / "temperature.jsonl") + path = sd / "temperature.jsonl" + if not path.exists(): + return [] + rows = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + pass # truncated or corrupt line — skip if since is not None: rows = [r for r in rows if str(r.get("t", "")) >= since] return rows diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index 069724a2..bcbef78f 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -103,7 +103,8 @@ function handleMessage(msg) { // per event and would lag every other handler), but still emit to the // client event bus so the Devices tab gets the payload. if (msg.event_type !== 'DEVICE_STATE_UPDATE' && - msg.event_type !== 'BOTTOM_CAMERA_FRAME') { + msg.event_type !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE') { handleFullEvent({ event_type: msg.event_type, data: msg.data, diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py index 3e208069..4c79bf76 100644 --- a/tests/test_temperature_sampler.py +++ b/tests/test_temperature_sampler.py @@ -71,3 +71,37 @@ def test_temperature_stamp_shapes(): assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", } + + +async def test_stale_latest_cleared_when_no_active_session(file_store): + """After a successful tick, a subsequent tick with no active session resets latest to None.""" + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + bus = EventBus() + + # Successful first tick. + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + assert s.latest is not None and s.latest["water_c"] == 28.4 + + # Second tick with no active session — latest must be cleared. + s._session_id_getter = lambda: None + await s._tick(bus) + assert s.latest is None + + +async def test_stale_latest_cleared_on_poll_failure(file_store): + """After a successful tick, a failing poll resets latest to None.""" + sid = file_store.create_session(str(uuid.uuid4()), name="s") + bus = EventBus() + + # Successful first tick. + good_scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + s = TemperatureSampler(good_scope, file_store, lambda: sid) + await s._tick(bus) + assert s.latest is not None + + # Swap to a scope that returns falsy (empty dict) — simulates device dropout. + s._microscope = FakeScope({}) + await s._tick(bus) + assert s.latest is None diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py index 57dfb603..4784880b 100644 --- a/tests/test_temperature_stamp.py +++ b/tests/test_temperature_stamp.py @@ -1,9 +1,11 @@ -"""Tests for Task 6: acquisition temperature stamp. +"""Tests for Task 6 & Task 7: acquisition temperature stamp. -Two concerns: +Concerns: 1. temperature_stamp(None) returns None (pure helper, no I/O). 2. Volume metadata round-trip: a stamp injected via put_volume(metadata={"temperature": stamp}) lands correctly in the sidecar YAML and is readable via get_volume_meta. +3. Burst stamp: _persist_burst_to_disk injects temperature into both burst.yaml and + each frame's .meta.yaml when a temperature_provider is supplied. Confirmed from file_store.py: - create_session(session_id, name=None, ...) — session_id is the first positional arg. @@ -12,15 +14,17 @@ under the "metadata" key in the sidecar YAML. - get_volume_meta(session_id, embryo_id, timepoint) — added by Task 6; reads sidecar YAML. -Note: tifffile is not installed in the test environment; it is mocked so put_volume -writes only the sidecar YAML (the contract under test) without the TIFF. +Note: tifffile is not installed in the test environment; it is mocked so put_volume / +_persist_burst_to_disk write only the YAML files (the contracts under test). """ import sys import uuid +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import numpy as np +import yaml from gently.app.temperature_sampler import temperature_stamp @@ -52,3 +56,63 @@ def test_volume_metadata_carries_temperature(file_store): meta = file_store.get_volume_meta(sid, emb, 0) assert meta["metadata"]["temperature"]["water_c"] == 28.4 + + +def test_burst_stamp_writes_temperature(file_store): + """_persist_burst_to_disk stamps temperature into burst.yaml and frame .meta.yaml.""" + from gently.app.orchestration.exclusive import _persist_burst_to_disk + + sid = file_store.create_session(str(uuid.uuid4()), name="burst-stamp-test") + embryo_id = "embryo_burst" + file_store.register_embryo(sid, embryo_id, position_x=0.0, position_y=0.0) + + # Minimal orchestrator stand-in: only _store and _session_id are needed. + orch = MagicMock() + orch._store = file_store + orch._session_id = sid + + # Minimal embryo stand-in. + embryo = MagicMock() + embryo.stage_position = {"x": 0.0, "y": 0.0} + embryo.num_slices = 2 + embryo.exposure_ms = 50.0 + + frames_data = [ + {"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None} + ] + + tifffile_mock = MagicMock() + with patch.dict(sys.modules, {"tifffile": tifffile_mock}): + burst_dir = _persist_burst_to_disk( + orchestrator=orch, + embryo=embryo, + embryo_id=embryo_id, + request_id="req-burst-001", + mode="1hz", + frames_requested=1, + frames_data=frames_data, + loop_start=datetime(2026, 6, 27, 10, 0, 0, tzinfo=timezone.utc), + duration_s=1.0, + sustained_hz=1.0, + galvo_amplitude=100.0, + galvo_center=0.0, + piezo_amplitude=50.0, + piezo_center=0.0, + laser_power_488_pct=10.0, + temperature_provider=lambda: { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + }, + ) + + assert burst_dir is not None, "_persist_burst_to_disk returned None — check store/session setup" + + # burst.yaml must have a top-level temperature with water_c == 28.4. + manifest = yaml.safe_load((burst_dir / "burst.yaml").read_text()) + assert manifest["temperature"]["water_c"] == 28.4 + + # Frame meta.yaml must have metadata.temperature.water_c == 28.4. + frame_meta = yaml.safe_load((burst_dir / "t0001.meta.yaml").read_text()) + assert frame_meta["metadata"]["temperature"]["water_c"] == 28.4 diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py index b51da792..9e5d5fbe 100644 --- a/tests/test_temperature_store.py +++ b/tests/test_temperature_store.py @@ -32,3 +32,27 @@ def test_read_since_filters(file_store): def test_read_unknown_session_is_empty(file_store): """Test that reading from a non-existent session returns empty list.""" assert file_store.read_temperature_log("does-not-exist") == [] + + +def test_truncated_trailing_line_is_skipped(file_store, tmp_path): + """A truncated trailing line (e.g. after a crash mid-append) is skipped gracefully.""" + import uuid + + sid = _new_session(file_store) + + # Append two valid samples via the normal API. + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"}) + + # Append a raw truncated line directly to the JSONL file. + from gently.core.file_store import FileStore + # Locate the temperature.jsonl via the store's internal path. + sd = file_store._session_dir(sid) + log_path = sd / "temperature.jsonl" + with open(log_path, "a", encoding="utf-8") as f: + f.write('{"t": "2026-06-27T10:00:03+00:00", "water_c":\n') + + # read_temperature_log must return only the two valid rows without raising. + rows = file_store.read_temperature_log(sid) + assert len(rows) == 2 + assert [r["water_c"] for r in rows] == [28.1, 28.2] From a79a9575ea7a880b7551075f281583902805eb51 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 06:40:22 +0530 Subject: [PATCH 082/200] fix(temperature): root-level temp graph colors for portability The water trace, gridlines, and tick labels used .devices-container-map-scoped --map-* vars, so the graph rendered blank outside that container. Promote them to :root-level --temp-* vars (mirroring --temp-setpoint-color) so the component is reusable when manual mode (sub-project B) re-mounts it. Same colors in-app. --- gently/ui/web/static/css/main.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 0b115fe6..36124bd9 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,8 +23,12 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; - /* Temperature graph colors */ + /* Temperature graph colors (root-level so the graph is portable outside + .devices-container-map — e.g. when reused in manual mode) */ --temp-setpoint-color: #f59e0b; + --temp-water-color: #22d3ee; + --temp-grid-line-color: rgba(212, 221, 232, 0.20); + --temp-grid-label-color: #6a778a; /* Gradients for modern feel */ --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #c084fc 100%); @@ -66,8 +70,11 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; - /* Temperature graph colors */ + /* Temperature graph colors (root-level; see dark block) */ --temp-setpoint-color: #f59e0b; + --temp-water-color: #0e7490; + --temp-grid-line-color: rgba(29, 43, 58, 0.22); + --temp-grid-label-color: #6b7280; /* Gradients */ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%); @@ -9751,7 +9758,7 @@ body.modal-open { } /* Solid line: water temperature */ .temp-water { - stroke: var(--map-accent); + stroke: var(--temp-water-color); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; @@ -9767,12 +9774,12 @@ body.modal-open { } /* Y-axis reference gridlines */ .temp-grid-line { - stroke: var(--map-ink-faint); + stroke: var(--temp-grid-line-color); stroke-width: 0.75; } /* Y-axis tick labels */ .temp-grid-label { - fill: var(--map-ink-mute); + fill: var(--temp-grid-label-color); font-size: 8px; font-family: ui-monospace, monospace; text-anchor: end; From 339cc7ce5e0ec2a80659dda19f1a447cec4c7dec Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:06:17 +0530 Subject: [PATCH 083/200] docs(manual-mode): design spec for SPIM single-slice live view (B1) --- ...2026-06-28-manual-mode-live-view-design.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md diff --git a/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md new file mode 100644 index 00000000..0ac1365a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md @@ -0,0 +1,190 @@ +# Design: Manual Mode — SPIM single-slice brightfield live view (sub-project B1) + +Status: design approved in brainstorm (2026-06-28). +Base branch: off `feature/temperature-interface` (sub-project A) — B1 embeds A's now-portable +temperature graph and its acquisition temperature stamp. + +## 0. Where this sits (execution roadmap) + +Sub-project **B** of the temperature-strain experiment prep. B is split: + +- **B1 (this spec) — the hand-driven experiment surface:** a **Manual view** in the Devices + tab with a SPIM single-slice brightfield live view (one selectable side A/B), live + galvo/piezo/exposure controls, brightfield illumination (LED, no laser), temperature + setpoint + A's graph, and by-hand burst/volume triggers. +- **B2 (deferred):** laser channel-preset browser, full timelapse/volume config form, + side-by-side dual view, richer camera selection. + +This is **load-bearing for next week** — the experiments are imaged by hand first. The first +experiment round is hand-driven through this view. + +## 1. Overview + +Today the Devices tab is read-only and there is no human-driven imaging surface. The +temperature experiments image with the **SPIM cameras under LED/brightfield illumination (no +laser, DIC/BF-like)** — a *single plane*, not a scan — and the operator needs to slide the +**galvo (sheet position)** and **imaging piezo (focal plane)** and *see the effect live*, then +trigger bursts/volumes by hand around a temperature change. + +### What already exists (reused) +- The MMCore core is a real `pymmcore.CMMCore`; **continuous sequence acquisition** is already + proven in the volume path (`acquisition.py:199-231`: `startSequenceAcquisition`/`popNextImage`/ + `getRemainingImageCount`/`stopSequenceAcquisition`). +- `capture_lightsheet_image(piezo_position, galvo_position, exposure_ms)` (`client.py:493`) — + a single-plane snap primitive (but it goes through the Bluesky queue → too heavy for a live + loop; used only for one-shot snaps). +- The **bottom-camera live streamer** (`device_layer.py` `_bottom_camera_streamer`) — the + structural template (subscriber-gated SSE, drop-oldest backpressure, `_state_pause_counter` + heavy-plan back-off, JPEG/base64 encode, EventBus→WS transport). +- Direct device writes: `set_led`, `set_laser_power`, `set_camera_led_mode`, `set_room_light`, + `set_temperature`, `move_to_position`, `acquire_volume`, `acquire_burst` (client methods). +- `require_control` auth (`auth.py`) — localhost = CONTROL on the rig; remote needs a token. +- The Devices view-switcher pattern (Map/Details/3D) to host a new "Manual" view. +- A's portable temperature-graph component + per-acquisition temperature stamp. + +### What must be built +A device-layer **lightsheet live streamer** (continuous sequence + peek-latest), an agent-side +**LightsheetStreamMonitor**, **`require_control` browser proxy routes**, two new client methods, +and the **Manual view** UI. Transport reuses the JSON/base64 path first; a binary path is a +**conditional escalation gated on an FPS measurement**. + +## 2. Architecture — seven units + +### 2.1 Lightsheet live streamer (device layer, new) +`_lightsheet_live_streamer` task + `handle_lightsheet_stream` SSE handler in `device_layer.py`, +mirroring the bottom-camera streamer's skeleton (subscriber-gated lifecycle, per-subscriber +`asyncio.Queue` drop-oldest backpressure, `_state_pause_counter` back-off). + +**Grab mechanism — the MM live pattern (replacing the snap loop):** +- On first subscriber: select the SPIM side camera (`core.setCameraDevice`), set exposure, + **park** galvo + piezo at the current params, `core.startContinuousSequenceAcquisition(0)`. +- Loop: **peek the latest** frame (`getLastImage` / `getNBeforeLastTaggedImage`) — never + `popNextImage` (don't drain). Encode → broadcast. Pace to `max(exposure, ~1/30 s)` up to the + display cap (MM's clamp). +- On last subscriber: `stopSequenceAcquisition`, restore prior camera/state. + +**Live param updates** (`POST /api/lightsheet/live/params {side, galvo, piezo, exposure}`): +- **galvo / piezo → applied live, no restart** (move scanner/imaging-piezo; next frames show + the new plane — the slide-and-see response). +- **exposure / side → stop → reconfigure → restart** (camera params can't change mid-sequence). + +**Resolution/quality:** the lightsheet stream gets its **own configurable size/quality** +(default ~512 px, JPEG q≈70) — higher than the bottom-camera 360 px thumbnail, since this view +is for judging focus. Resolution↔FPS tradeoff feeds the §3 measurement. + +**rpyc caveat:** the core may be an rpyc proxy, so each `getLastImage` transfers a full frame +across that boundary *inside the device-layer process*; the streamer encodes to JPEG there, so +only a small payload leaves the process. rpyc bounds the *grab* rate, not the web payload. + +**Implementer confirmations (flagged, not guesses):** the exact device methods to park the +galvo (scanner) + imaging piezo and the SPIM side-camera device names +(`device_factory.py`/`devices/scanner.py`/`optical.py`); and whether brightfield/LED live needs +the scanner/beam enabled at all or a fully static park — confirm against +`../micro-manager/plugins/ASIdiSPIM/.../SetupPanel.java`. B1 assumes static park, laser off. + +### 2.2 Lightsheet stream monitor (agent Service, new) +Mirrors `BottomCameraStreamMonitor`: consumes the device SSE via a new client generator and +republishes frames on the EventBus as a new high-volume `EventType.LIGHTSHEET_FRAME` +(added to `_NO_HISTORY_TYPES` and the `websocket.js` events-table exclusion). Opt-in; +started/stopped via the proxy routes. + +### 2.3 Browser proxy routes (viz server, `require_control`, new) +In `data.py`'s `create_router`, each `Depends(require_control)`, each resolving the live client +via `_resolve_client()`: + +| Route | Calls | +|---|---| +| `POST /api/devices/lightsheet/live/start` | start `LightsheetStreamMonitor` | +| `POST /api/devices/lightsheet/live/stop` | stop the monitor | +| `GET /api/devices/lightsheet/live/status` | `{available, streaming, side, last_frame_ts, fps}` | +| `POST /api/devices/lightsheet/live/params` | `client.set_lightsheet_live_params(side,galvo,piezo,exposure)` | +| `POST /api/devices/led/set` | `client.set_led(state)` | +| `POST /api/devices/laser/off` | `client.set_laser_power(wl, 0)` / "ALL OFF" | +| `POST /api/devices/camera/led_mode` | `client.set_camera_led_mode(use_led)` | +| `POST /api/devices/room_light/set` | *(exists `data.py:335`)* | +| `POST /api/devices/stage/move` | `client.move_to_position(x,y)` | +| `POST /api/devices/acquire/burst` | `client.acquire_burst(...)` | +| `POST /api/devices/acquire/volume` | `client.acquire_volume(...)` | +| `POST /api/devices/temperature/set` | *(exists `data.py:381`)* | + +The device-layer aiohttp routes have no auth; the browser must go through this proxy. + +### 2.4 New client methods +`DiSPIMMicroscope.stream_lightsheet(...)` (async generator over `GET /api/lightsheet/stream`, +mirroring `stream_bottom_camera`) and `set_lightsheet_live_params(side, galvo, piezo, exposure)` +(`POST /api/lightsheet/live/params`). Everything else already exists. + +### 2.5 Manual view (frontend, new `#devices-view-manual`) +A "Manual" entry in the Devices view-switcher → two-column layout: +- **Left:** the live image canvas (reusing the bottom-camera render + `zoom-pan.js` + + crosshair), an FPS + side overlay, and a Start/Stop Live toggle; a "Last capture" card below. +- **Right control rail:** Camera (side A/B selector, exposure) · Focus & sheet (galvo + piezo + sliders with numeric fields + nudge) · Illumination (LED, camera-LED-mode, room light, laser + OFF indicator) · Temperature (setpoint + A's embedded graph) · Acquire (Snap volume / Burst + buttons with a small param summary). + +Interactions: galvo/piezo sliders → **debounced** `POST live/params` → canvas updates in a +couple of frames; acquire → "acquiring…" overlay → Last-capture card → live resumes; FPS +readout surfaces the §3 measurement. Toggle/setpoint widgets model on the existing room-light +and temp-set controls. + +### 2.6 Concurrency rule +Live holds the camera/MMCore, so a triggered burst/volume **stops the live sequence → acquires +→ resumes** (via the existing `_state_pause_counter` back-off). Only **one live stream at a +time** (lightsheet vs bottom contend for MMCore). Acquisitions land with A's temperature stamp. + +### 2.7 Brightfield safety +On entering manual/brightfield live, **laser is forced off**; `DiSPIMLightSource` power-limit +clamps already cap any laser power. + +## 3. Transport & the FPS measure→escalate path (Approach ①) + +**Baseline (build first):** lightsheet frames ride the existing path unchanged — +device base64-JPEG-in-JSON over SSE → `LightsheetStreamMonitor` → EventBus `LIGHTSHEET_FRAME` +→ `ConnectionManager.broadcast` (`json.dumps` + `send_text`) → browser canvas. Additions: the +new EventType (+ `_NO_HISTORY_TYPES` + `websocket.js` exclusion) and a browser paint handler. + +**Instrumented measurement (the gate):** three counters — **device grab rate**, **delivered +rate**, **browser paint rate** (the FPS readout). Target **≥ ~15 fps usable** (stretch 25–30), +latency < ~150 ms. Run on the rig; record the numbers in the plan. + +**Diagnosis fork:** +- device grab < target → limiter is exposure / readout / rpyc, *not* transport → tune + exposure / size / quality; binary won't help. +- device grab ≥ target but browser paint < target → transport is the bottleneck → escalate. + +**Escalation (conditional task):** a **binary WebSocket path on the agent→browser hop** — +`websocket.send_bytes(jpeg)` with a small type prefix, browser `createImageBitmap` → canvas — +bypassing base64 (+33%), the per-client `json.dumps`, and the EventBus fan-out. The device→agent +SSE stays (single consumer). Built **only if** the measurement points to transport. + +## 4. Error handling & edges +- No SPIM camera / device down → `live/status {available:false}` → empty canvas, controls + disabled. Sequence fails to start → surfaced, graceful (no crash). +- rpyc/slow grab → backpressure drops oldest; FPS readout shows reality. +- Param out of range → `DiSPIMLightSource` clamps + UI input bounds; laser forced off in + brightfield. +- Only one live stream at a time; live is subscriber-gated → stops on view-switch / page-close. +- `require_control` denied (remote, no token) → 403 → controls shown disabled. + +## 5. Testing +- **Device layer:** streamer against a fake core supporting sequence acquisition + (start/stop/`getLastImage`) — param updates (galvo/piezo live vs exposure/side restart), + heavy-plan pause/resume, subscriber lifecycle. +- **Proxy routes:** FastAPI TestClient + mock client — `require_control` 403 gate, param + forwarding. +- **Client methods:** `stream_lightsheet` SSE parsing; `set_lightsheet_live_params` POST. +- **Frontend:** `node --check` + a Chrome-MCP harness (like A) for canvas + slide-and-see + + states, plus a UI audit. (No JS unit harness in repo.) +- **FPS:** a documented **rig measurement step** (grab/deliver/paint fps); the binary path is a + **conditional follow-up task gated on those numbers**. +- **Integration (rig):** live renders; slider changes the image; burst persists with + temperature; live resumes. + +Much of B1 needs the **real rig** (rpyc core, SPIM cameras, galvo/piezo) to verify; off-rig we +cover the streamer (fake core), proxy routes, client methods, and the frontend harness, and +defer live/FPS verification to the microscope — as with A. + +## 6. Out of scope (B2) +Laser channel-preset browser; full timelapse/volume configuration form; side-by-side dual view; +richer camera selection. From 2041c3aebda084d98c28e64b2c14811e02ea2903 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:24:13 +0530 Subject: [PATCH 084/200] docs(manual-mode): B1 plan (7 tasks) + ASIdiSPIM two-camera model appendix --- .../plans/2026-06-28-manual-mode-live-view.md | 728 ++++++++++++++++++ ...2026-06-28-manual-mode-live-view-design.md | 28 + 2 files changed, 756 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-manual-mode-live-view.md diff --git a/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md new file mode 100644 index 00000000..8c84eeeb --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md @@ -0,0 +1,728 @@ +# Manual Mode — SPIM Live View (B1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Manual view in the Devices tab with a continuous SPIM single-slice brightfield live view (galvo/piezo/exposure controlled live), brightfield illumination, temperature, and by-hand burst/volume triggers — the hand-driven surface for next week's temperature-strain experiments. + +**Architecture:** A device-layer lightsheet live streamer (MMCore continuous sequence acquisition + peek-latest, parked galvo/piezo), bridged to the browser by a `LightSheetStreamMonitor` (EventBus `LIGHTSHEET_FRAME`) over the existing base64-JPEG/SSE→WS transport; `require_control` FastAPI proxy routes; a Manual view that mirrors the bottom-camera panel. FPS is measured on the rig; a binary transport path is a conditional follow-up. + +**Tech Stack:** Python asyncio + aiohttp (device layer), FastAPI (viz proxy), `pymmcore.CMMCore`, the project EventBus, vanilla-JS + canvas/SVG frontend (no build step), pytest (`asyncio_mode=auto`). + +## Global Constraints + +- **No new dependency.** Reuse `_encode_frame_for_stream` (OpenCV already present), the bottom-camera streamer pattern, A's portable temperature graph. +- **Single SPIM camera** today: `self.devices.get("camera")` (`HamCam1`). No side-A/B selector in B1 (deferred to B2). +- **Live = continuous sequence acquisition**, never a snap loop: `core.startContinuousSequenceAcquisition(0)` → peek `core.getLastImage()` (NEVER `popNextImage` — don't drain) → `stopSequenceAcquisition()` on exit. The core handle is `self.system.core` in the device layer; unwrap rpyc frames with `_safe_obtain`. +- **Park** before/under live: `piezo.setPosition(z)`, `scanner.sa_offset_y.setPosition(deg)` (or `scanner.set_y_offset(deg)`), `scanner.set_spim_state("Idle")`, `piezo.set_spim_state("Idle")`. galvo/piezo updates apply live (no restart); exposure change → stop→`setExposure`→restart. +- **Brightfield safety:** laser forced off in manual live (`setConfig(laser_group, "ALL OFF")` / `set_laser_power(...,0)`). +- **Concurrency:** the streamer honors `self._state_pause_counter > 0` (heavy plan owns MMCore → back off / stop sequence). Only one live stream at a time. +- **Lightsheet stream resolution/quality:** its own config — default `_ls_target_max_dim = 512`, `_ls_jpeg_quality = 70` (higher than the bottom-camera 360/55 thumbnail, for focus). +- **Transport stays JSON/`send_text`** (no binary path exists); a binary hop is Task 8, conditional on the FPS measurement. +- **Auth:** browser-facing writes are FastAPI proxy routes guarded by `Depends(require_control)`; device-layer aiohttp routes have no auth, so the browser must go through the proxy. +- **Tests:** `pytest`; `file_store`-style fixtures; FastAPI `TestClient` + mock client for routes; a fake core for the streamer. Frontend has no JS unit harness → `node --check` + Chrome-MCP harness + UI audit. Much of B1 needs the real rig; off-rig we cover streamer (fake core), routes, client, frontend harness, and defer live/FPS verification. + +--- + +### Task 1: `LIGHTSHEET_FRAME` event type + frontend exclusion + +**Files:** +- Modify: `gently/core/event_bus.py` (enum near `:88`; `_NO_HISTORY_TYPES` near `:186-195`) +- Modify: `gently/ui/web/static/js/websocket.js` (exclusion guard `:104-107`) +- Test: `tests/test_lightsheet_event.py` + +**Interfaces:** +- Produces: `EventType.LIGHTSHEET_FRAME` (declared with `auto()`, matching `BOTTOM_CAMERA_FRAME`), in `_NO_HISTORY_TYPES`. Wire serialization uses `.name` (so the browser receives `"LIGHTSHEET_FRAME"`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_event.py +from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + +def test_lightsheet_frame_event_exists(): + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + +def test_lightsheet_frame_excluded_from_history(): + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + +def test_lightsheet_frame_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] +``` + +> Confirm `EventBus.subscribe` signature / `_NO_HISTORY_TYPES` exportability against the real file; adapt the import/subscribe if needed, keep the assertions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_lightsheet_event.py -v` +Expected: FAIL — `AttributeError: LIGHTSHEET_FRAME` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/core/event_bus.py`, add the member next to `BOTTOM_CAMERA_FRAME` (`:88`): +```python + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream +``` +Add to `_NO_HISTORY_TYPES` (next to `BOTTOM_CAMERA_FRAME`): +```python + EventType.LIGHTSHEET_FRAME, # high-volume live frames — keep out of history +``` +In `gently/ui/web/static/js/websocket.js`, extend the exclusion guard (`:104-107`) so the frame skips the Events table but still reaches `ClientEventBus.emit`: +```javascript + if (msg.event_type !== 'DEVICE_STATE_UPDATE' && + msg.event_type !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_lightsheet_event.py -v` (3 passed) and `node --check gently/ui/web/static/js/websocket.js` (exit 0) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py gently/ui/web/static/js/websocket.js tests/test_lightsheet_event.py +git commit -m "feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exclusion" +``` + +--- + +### Task 2: Device-layer lightsheet live streamer (continuous sequence acquisition) + +**Files:** +- Modify: `gently/hardware/dispim/device_layer.py` — add config attrs (near `:160-169`), `_park_lightsheet_sync`, `_grab_lightsheet_frame_sync`, `_lightsheet_streamer`, `_broadcast_lightsheet`, `handle_lightsheet_stream`, `handle_lightsheet_params`; register two routes (near `:2806`). +- Test: `tests/test_lightsheet_streamer.py` +- Reference (mirror): `_bottom_camera_streamer`/`_broadcast_camera`/`handle_bottom_camera_stream`/`_encode_frame_for_stream` (same file); sequence-acq calls in `devices/acquisition.py:186-254`. + +**Interfaces:** +- Consumes: `self.system.core` (`pymmcore.CMMCore`), `self.devices["camera"]` / `["scanner"]` / `["piezo"]`, `self._state_pause_counter`, `self._encode_frame_for_stream` (reused verbatim), `_safe_obtain` (rpyc unwrap, imported as in `acquisition.py`). +- Produces: SSE `GET /api/lightsheet/stream`; `POST /api/lightsheet/live/params` `{galvo, piezo, exposure}`; in-process live param state `self._ls_params`. + +> **Implementer confirmations (cannot be verified off-rig — confirm against the real code, do not guess silently):** +> 1. The device-layer core handle (`self.system.core`) and that `pymmcore.CMMCore` exposes `startContinuousSequenceAcquisition(float)`, `getLastImage()`, `stopSequenceAcquisition()`, `isSequenceRunning()` (standard CMMCore API). If `getLastImage` is unavailable, use `getLastImageMD`/`getNBeforeLastImage`. +> 2. The scanner/piezo park calls: `self.devices["scanner"].sa_offset_y.setPosition(deg)` and `self.devices["piezo"].setPosition(z)`, and `set_spim_state("Idle")` on both (from `devices/scanner.py:271`, `devices/piezo.py:226`, recon §3). +> 3. Whether brightfield live needs the scanner/beam enabled or a static park — confirm against `../micro-manager/plugins/ASIdiSPIM/src/main/java/org/micromanager/asidispim/SetupPanel.java`. Default: static park, laser "ALL OFF". + +- [ ] **Step 1: Write the failing test (fake core + fake devices)** + +```python +# tests/test_lightsheet_streamer.py +import asyncio, numpy as np, pytest +from gently.hardware.dispim.device_layer import DeviceLayer # confirm class name/import + +class FakeCore: + def __init__(self): + self.running = False; self.exposure = None; self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0; self.stopped = 0 + def setCameraDevice(self, n): self.cam = n + def getCameraDevice(self): return self.cam + def setExposure(self, n, ms): self.exposure = ms + def startContinuousSequenceAcquisition(self, interval): self.running = True; self.started += 1 + def stopSequenceAcquisition(self): self.running = False; self.stopped += 1 + def isSequenceRunning(self): return self.running + def getLastImage(self): return self._frame + +class FakeAxis: + def __init__(self): self.pos = None + def setPosition(self, v): self.pos = v + +class FakeScanner: + def __init__(self): self.sa_offset_y = FakeAxis(); self.name="Scanner"; self.state=None + def set_spim_state(self, s): self.state = s + +class FakePiezo(FakeAxis): + def __init__(self): super().__init__(); self.name="Piezo"; self.state=None + def set_spim_state(self, s): self.state = s + +def _streamer(dl): + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = {"camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), "piezo": FakePiezo()} + return dl + +async def test_grab_parks_and_peeks(monkeypatch): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + dl._ls_seq_started = False; dl._ls_applied = {} + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + +async def test_exposure_change_restarts_sequence(): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + dl._ls_seq_started = False; dl._ls_applied = {} + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: FAIL — `AttributeError: _grab_lightsheet_frame_sync` (or import). + +- [ ] **Step 3: Implement the streamer** + +Add config attrs in `__init__` (near `:169`, after the `_cam_*` block): +```python + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows + self._ls_target_max_dim: int = 512 + self._ls_jpeg_quality: int = 70 + self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} + self._ls_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure +``` + +Add the grab/park/peek (mirrors the sequence-acq calls from `acquisition.py`; uses `self.system.core`): +```python + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + p = self._ls_params + scanner = self.devices.get("scanner") + piezo = self.devices.get("piezo") + if scanner is not None: + try: scanner.set_spim_state("Idle") + except Exception: pass + scanner.sa_offset_y.setPosition(float(p["galvo"])) + if piezo is not None: + try: piezo.set_spim_state("Idle") + except Exception: pass + piezo.setPosition(float(p["piezo"])) + + def _ensure_lightsheet_sequence_sync(self) -> None: + """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + core = self.system.core + cam = self.devices.get("camera") + if cam is None: + raise RuntimeError("No lightsheet camera configured") + p = self._ls_params + need_restart = ( + not self._ls_seq_started + or self._ls_applied.get("exposure") != p["exposure"] + ) + if need_restart: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + + def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + from gently.hardware.dispim.devices.acquisition import _safe_obtain + core = self.system.core + img = core.getLastImage() + try: img = _safe_obtain(img) + except (ImportError, AttributeError): pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} +``` + +Add the streamer loop + broadcast (mirror `_bottom_camera_streamer`/`_broadcast_camera`, reusing `_encode_frame_for_stream`): +```python + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = self._encode_frame_for_stream(img) if img is not None else None + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload): + if not self._ls_subscribers: + return + dead = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait(); q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: self._ls_subscribers.remove(q) + except ValueError: pass +``` + +> `_encode_frame_for_stream` uses `self._cam_target_max_dim`/`self._cam_jpeg_quality`. To get the 512/70 lightsheet settings without duplicating the encoder, add an optional override: change its signature to `_encode_frame_for_stream(self, img, max_dim=None, quality=None)` defaulting to the `_cam_*` values, and call it `self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality)` from the lightsheet loop. (One-line change to the encoder; bottom-camera behavior unchanged.) + +Add the SSE handler + params handler (mirror `handle_bottom_camera_stream`; the params handler updates `self._ls_params` — galvo/piezo apply on the next grab, exposure triggers the restart path): +```python + async def handle_lightsheet_stream(self, request): + response = web.StreamResponse(status=200, reason="OK", headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache", + "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + await response.prepare(request) + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task(self._lightsheet_streamer(), name="lightsheet-streamer") + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n"); continue + if payload is None: break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: self._ls_subscribers.remove(queue) + except ValueError: pass + return response + + async def handle_lightsheet_params(self, request): + body = await request.json() + for k in ("galvo", "piezo", "exposure"): + if k in body and body[k] is not None: + self._ls_params[k] = float(body[k]) + return web.json_response({"params": self._ls_params}) +``` + +Register both routes near `:2806`: +```python + self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) + self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: PASS (2 passed). If `DeviceLayer.__new__` bypassing `__init__` leaves attrs unset, the test sets the needed ones explicitly (it does). + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/device_layer.py tests/test_lightsheet_streamer.py +git commit -m "feat(manual-mode): device-layer lightsheet live streamer (continuous sequence acquisition)" +``` + +--- + +### Task 3: Client methods — `stream_lightsheet` + `set_lightsheet_live_params` + +**Files:** +- Modify: `gently/hardware/dispim/client.py` (add two methods on `DiSPIMMicroscope`, near `stream_bottom_camera` `:913`) +- Test: `tests/test_lightsheet_client.py` + +**Interfaces:** +- Produces: `async def stream_lightsheet(self, timeout=None)` (async generator over `GET /api/lightsheet/stream`, identical SSE parse to `stream_bottom_camera`); `async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict` (`POST /api/lightsheet/live/params`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_client.py +import pytest +from gently.hardware.dispim.client import DiSPIMMicroscope + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + async def fake_post(path, body): + sent["path"] = path; sent["body"] = body; return {"params": body} + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + assert inspect.isasyncgenfunction(m.stream_lightsheet) +``` + +> Confirm the real low-level POST helper (the recon shows `set_led` etc. POST via an internal helper — find whether it's `self._api_post(path, body)` or an inline `self._session.post`). Match it; if `set_lightsheet_live_params` should drop `None` keys, build the body from only the provided args (the test passes all three). + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_client.py -v` → FAIL (no such methods). + +- [ ] **Step 3: Implement** + +```python + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout(total=None, sock_read=timeout, sock_connect=10.0) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: body["galvo"] = float(galvo) + if piezo is not None: body["piezo"] = float(piezo) + if exposure is not None: body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) +``` + +> If the real POST helper isn't `_api_post`, adapt this one call site (and the test) to the real helper. `stream_lightsheet` copies `stream_bottom_camera` verbatim except the URL. + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_client.py -v` (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/client.py tests/test_lightsheet_client.py +git commit -m "feat(manual-mode): client stream_lightsheet + set_lightsheet_live_params" +``` + +--- + +### Task 4: `LightSheetStreamMonitor` (agent Service) + agent wiring + +**Files:** +- Create: `gently/app/lightsheet_monitor.py` +- Modify: `gently/app/agent.py` (init attr; construct in `start_viz_server` near the bottom-camera monitor `:849-860`; stop in `stop_viz_server` near `:862-869`) +- Test: `tests/test_lightsheet_monitor.py` +- Reference (mirror verbatim, swapping names/event): `gently/app/bottom_camera_monitor.py` + +**Interfaces:** +- Consumes: `microscope.stream_lightsheet()` (Task 3); `EventType.LIGHTSHEET_FRAME` (Task 1). +- Produces: `LightSheetStreamMonitor(Service)` with `running` property, `on_start`/`on_stop`; publishes `LIGHTSHEET_FRAME`. `agent.lightsheet_monitor` (constructed, not started; started via proxy in Task 5). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_monitor.py +import asyncio +from gently.core.event_bus import EventType, get_event_bus +from gently.app.lightsheet_monitor import LightSheetStreamMonitor + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False +``` + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_monitor.py -v` → FAIL (no module). + +- [ ] **Step 3: Implement** — copy `gently/app/bottom_camera_monitor.py` to `gently/app/lightsheet_monitor.py` and change: class → `LightSheetStreamMonitor`; `name="lightsheet-monitor"`; the `_run` loop calls `self.microscope.stream_lightsheet()` and publishes `EventType.LIGHTSHEET_FRAME` with `source="lightsheet-monitor"`. (Everything else — Service base, on_start/on_stop, reconnect loop, `_last_frame_ts`, `running` — is identical.) + +Agent wiring in `gently/app/agent.py`: add `self.lightsheet_monitor = None` next to `self.bottom_camera_monitor = None`; in `start_viz_server` (after the bottom-camera monitor construction `:849-860`): +```python + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None +``` +In `stop_viz_server` (near `:862`): +```python + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None +``` + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_monitor.py -v` (1 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/lightsheet_monitor.py gently/app/agent.py tests/test_lightsheet_monitor.py +git commit -m "feat(manual-mode): LightSheetStreamMonitor bridge + agent wiring" +``` + +--- + +### Task 5: Browser proxy routes (`require_control`) + +**Files:** +- Modify: `gently/ui/web/routes/data.py` (add routes in `create_router`, mirroring the bottom-camera + room-light routes) +- Test: `tests/test_lightsheet_routes.py` +- Reference: bottom-camera start/stop/status (`data.py:260-311`), room-light proxy (`data.py:335-355`), `_resolve_client` (`:313`), `require_control` (`:10`). + +**Interfaces:** +- Consumes: `agent.lightsheet_monitor` (Task 4); `client.set_lightsheet_live_params`, `set_led`, `set_laser_power`, `set_camera_led_mode`, `move_to_position`, `acquire_burst`, `acquire_volume`. +- Produces (all `Depends(require_control)` except GET status): `POST /api/devices/lightsheet/live/{start,stop}`, `GET /api/devices/lightsheet/live/status`, `POST /api/devices/lightsheet/live/params`, `POST /api/devices/led/set`, `POST /api/devices/laser/off`, `POST /api/devices/camera/led_mode`, `POST /api/devices/stage/move`, `POST /api/devices/acquire/{burst,volume}`. + +- [ ] **Step 1: Write the failing test (TestClient + mock client/monitor)** + +```python +# tests/test_lightsheet_routes.py +from unittest.mock import MagicMock, AsyncMock +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.data import create_router +import gently.ui.web.auth as auth + +def _app(client=None, monitor=None): + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI(); app.include_router(create_router(server)) + # legacy localhost = CONTROL; TestClient client.host is "testclient" → force CONTROL: + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + +def test_live_params_forwards(): + client = MagicMock(); client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post("/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + +def test_acquire_burst_forwards(): + client = MagicMock(); client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post("/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + +def test_live_start_requires_monitor(): + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 +``` + +> Confirm the `require_control` override mechanism: in legacy mode `TestClient` requests are not localhost, so override the dependency (as above) to isolate route logic. A separate test can assert the gate by NOT overriding and expecting 403. + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_routes.py -v` → FAIL (routes 404). + +- [ ] **Step 3: Implement** — in `create_router`, add (mirroring the referenced routes). Live start/stop/status copy the bottom-camera versions verbatim, swapping `bottom_camera_monitor` → `lightsheet_monitor`. Then: +```python + @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) + async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_lightsheet_live_params( + galvo=payload.get("galvo"), piezo=payload.get("piezo"), + exposure=payload.get("exposure")) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_laser_power(488, 0) # confirm signature; force off + except Exception as exc: + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) + async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: raise HTTPException(status_code=400, detail="x and y required") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc +``` +Add the live start/stop/status routes by copying `start_bottom_camera_stream`/`stop_bottom_camera_stream`/`get_bottom_camera_status` (`data.py:260-311`) under `/api/devices/lightsheet/live/...` with `getattr(agent, "lightsheet_monitor", None)`. + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_routes.py -v` (3 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/data.py tests/test_lightsheet_routes.py +git commit -m "feat(manual-mode): require_control proxy routes for lightsheet live + illumination + acquire" +``` + +--- + +### Task 6: Manual view (frontend) + +**Files:** +- Modify: `gently/ui/web/templates/index.html` — add a `data-view="manual"` button to `#devices-view-switcher` (`:445-449`); add a `#devices-view-manual` container with the live canvas + control rail (model the camera panel `:569-608`); load A's `temperature-graph.js` if not already loaded on this page. +- Modify: `gently/ui/web/static/js/devices.js` — add `'manual'` to `VIEWS` (`:17`); a `handleLightsheetFrame` (mirror `handleCameraFrame` `:1111`); live toggle (mirror `toggleCameraStream`); galvo/piezo/exposure controls (debounced POST `live/params`); illumination toggles; acquire buttons; FPS readout; embed `TemperatureGraph.init`; a `'v'`-style key handled only if not deferred — leave existing keys, add nothing conflicting. +- Modify: the devices stylesheet for the manual panel (reuse `.devices-camera-*` styles where possible). + +**No JS unit harness** — verified by `node --check` + a Chrome-MCP harness (like A) + a UI audit. + +- [ ] **Step 1: Markup** — add to `#devices-view-switcher`: +```html + +``` +Add the view container after `#devices-view-optical3d` (`:716`-ish), with: a live `` (or ``) + placeholder + FPS/side overlay + Start/Stop toggle (`#devices-ls-toggle`); a right rail with exposure input, galvo slider (`#devices-ls-galvo`), piezo slider (`#devices-ls-piezo`), illumination toggles (LED `#devices-ls-led`, camera-LED-mode, room light, a static "Laser: OFF" indicator), a temperature setpoint + `
    `, and Snap-volume / Burst buttons + a `#devices-ls-lastcap` card. Mirror the `.devices-camera-*` class structure for the image stage so the existing zoom/pan inline machinery can be reused or replicated. + +- [ ] **Step 2: JS — frame paint + controls** + +Add `'manual'` to `VIEWS`. Add a frame handler mirroring `handleCameraFrame` (separate DOM ids `_lsImg`/`_lsMeta`, its own FPS window) and subscribe `ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame)` in `setupCameraWiring` (or a new `setupManualWiring`). Live toggle hits `/api/devices/lightsheet/live/start|stop`. Galvo/piezo/exposure inputs: on `input`, **debounce ~120 ms**, then `fetch('/api/devices/lightsheet/live/params', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({galvo, piezo, exposure})})`. Illumination toggles POST their routes. Acquire buttons POST `/api/devices/acquire/burst|volume` with the current params, show "acquiring…" then render the result ref in `#devices-ls-lastcap`. Embed A's graph: `if (window.TemperatureGraph) TemperatureGraph.init(document.getElementById('devices-ls-tempgraph'), 'current')`. FPS readout from the frame handler's window (reuse the `computeCameraFps` approach). + +- [ ] **Step 3: `node --check`** — `node --check gently/ui/web/static/js/devices.js` (exit 0). + +- [ ] **Step 4: Chrome-MCP harness verification** — build a standalone harness (like A's): copy `event-bus.js`, `temperature-graph.js`, the new manual JS, and `main.css`; stub `fetch` for `live/params` + status + acquire; feed simulated `LIGHTSHEET_FRAME` events (a moving synthetic gradient that shifts when galvo/piezo "params" change) to demonstrate the slide-and-see; screenshot; run the alignment/spacing/contrast UI audit and fix flaws. Live in-app + FPS verification is deferred to the rig. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js gently/ui/web/static/css/main.css +git commit -m "feat(manual-mode): Manual view — SPIM live canvas, galvo/piezo/exposure, illumination, acquire, temp" +``` + +--- + +### Task 7: FPS measurement (rig) + conditional binary transport + +**Files:** +- Create: `docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md` (record the numbers) +- (Conditional) Modify: `gently/ui/web/connection_manager.py` + `agent_ws`/`server.py` + `websocket.js` for a binary frame path. + +**This task is a measurement + a gate, not unconditional code.** + +- [ ] **Step 1:** On the rig, start lightsheet live and record from the Manual-view FPS readout + device logs: **device grab rate**, **delivered rate**, **browser paint rate**, at 512 px/q70 and at a reduced 384 px/q60. Write them into the notes file with the exposure used. +- [ ] **Step 2: Diagnose.** If device grab < ~15 fps → limiter is exposure/readout/rpyc, not transport — tune exposure/size/quality, stop here. If device grab ≥ target but browser paint < target → transport is the bottleneck → do Step 3. +- [ ] **Step 3 (conditional): binary WebSocket path.** Add a `send_bytes`-based frame channel: device→agent SSE stays; on the agent→browser hop, push the raw JPEG via `websocket.send_bytes(prefix + jpeg)` (small type byte), bypassing base64 + the per-client `json.dumps` + the EventBus fan-out; browser `onmessage` binary → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`. Re-measure and record. +- [ ] **Step 4: Commit** the notes (and any binary-path code, if built). + +```bash +git add docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md +git commit -m "docs(manual-mode): lightsheet live FPS measurement + transport decision" +``` + +--- + +## Self-Review + +**Spec coverage:** §2.1 streamer → Task 2; §2.2 monitor → Task 4; §2.3 proxy routes → Task 5; §2.4 client methods → Task 3; §2.5 Manual view → Task 6; §2.6 concurrency (`_state_pause_counter` back-off) → Task 2 streamer loop; §2.7 brightfield laser-off → Tasks 2 & 5 (`laser/off`); §3 transport baseline + measurement + binary escalation → Tasks 1/4 (baseline) + Task 7 (measure/escalate); §4 error handling → Tasks 2/5 (try/except, 503/502); §5 testing → each task's tests + Task 6 harness; LIGHTSHEET_FRAME event → Task 1. Single-camera (no side A/B) reflected throughout. ✓ + +**Open confirmations (explicit, not placeholders):** pymmcore sequence-acq method availability + core handle (Task 2); scanner/piezo park method names (Task 2); the SetupPanel scanner/beam question (Task 2); the client low-level POST helper name (Task 3); the `require_control` test-override mechanism (Task 5). Each names a concrete fallback. + +**Type consistency:** frame payload `{t, shape, downsample, mime, jpeg_b64}` (reused encoder) across Tasks 2/3/4/6; live params `{galvo, piezo, exposure}` across Tasks 2/3/5/6; event `LIGHTSHEET_FRAME` across Tasks 1/4/6; `lightsheet_monitor` attr across Tasks 4/5. Consistent. diff --git a/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md index 0ac1365a..3d846a36 100644 --- a/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md +++ b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md @@ -188,3 +188,31 @@ defer live/FPS verification to the microscope — as with A. ## 6. Out of scope (B2) Laser channel-preset browser; full timelapse/volume configuration form; side-by-side dual view; richer camera selection. + +## 7. Appendix: ASIdiSPIM two-camera model (B2 reference) + +From the MM ASIdiSPIM plugin (`../micro-manager/plugins/ASIdiSPIM`), for when B2 adds side A/B: + +- **Camera roles** (`data/Devices.java:93-121`): `CAMERAA` (side-A imaging), `CAMERAB` + (side-B imaging), `CAMERALOWER` (bottom), `MULTICAMERA` (the MMCore *Utilities* "Multi Camera" + fusion adapter). `SPIM_CAMERAS = {CAMERAA, CAMERAB}`; a `Sides` enum (A/B/NONE). +- **Live / single-side select** (`data/Cameras.java:163-186`, `setCamera`): `core.setCameraDevice( + mmDevice)` bracketed by stop/start-live; `getCurrentCamera` (`:193-210`) treats the Core as the + source of truth. Live can target one side **or** `MULTICAMERA` (dual live via the Utilities + Multi-Camera device). +- **Multi-Camera discovery** (`DevicesPanel.java:167-180`): auto-detected by + `getDeviceLibrary=="Utilities"` AND `getDeviceDescription=="Combine multiple physical cameras + into a single logical camera"`. +- **Acquisition does NOT use the fusion device** (`AcquisitionPanel.java:2668-2671`): it runs + **two parallel `startSequenceAcquisition`** on the physical side cameras into one shared buffer + (equal ROI required) and **demuxes by the per-image `"Camera"` tag** (`:2736-2772`; side A → even + channel indices, side B → odd). The Utilities Multi-Camera is reserved for **dual live**. + +**Implication for gently:** B1's streamer already does `core.setCameraDevice(cam.name)` before the +continuous sequence — that IS the MM side-select point. To add side A/B in **B2**: register +`CAMERAA`/`CAMERAB` (and auto-discover the Utilities "Multi Camera") in `device_factory.py`; add a +camera-role selector that sets the device before starting the live sequence; for dual **live**, +point the sequence at the Multi-Camera device; for dual **acquisition**, either use the +Multi-Camera or replicate the two-sequence + `"Camera"`-tag demux. Correction to the working +assumption: ASIdiSPIM acquisition is **two independent sequences demuxed by tag**, not the +multicamera fusion — the fusion device is a live-only convenience. From ec917e06c2cd9063bf9f3292ef32b708be408b1a Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:27:24 +0530 Subject: [PATCH 085/200] feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exclusion --- gently/core/event_bus.py | 2 ++ gently/ui/web/static/js/websocket.js | 3 ++- tests/test_lightsheet_event.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/test_lightsheet_event.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index b3bd7e68..9774bdaa 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -86,6 +86,7 @@ class EventType(Enum): DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer TEMPERATURE_UPDATE = auto() # Temperature reading from device layer BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view @@ -188,6 +189,7 @@ class EventType(Enum): EventType.DEVICE_STATE_UPDATE, EventType.TEMPERATURE_UPDATE, # High-volume telemetry from temperature controller EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out + EventType.LIGHTSHEET_FRAME, # High-volume live frames — keep out of history EventType.LOG_RECORD, # log lines can hit hundreds/min during # calibration; durable copy is in the # gently_*.log file already diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index bcbef78f..cd2ba5d5 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -104,7 +104,8 @@ function handleMessage(msg) { // client event bus so the Devices tab gets the payload. if (msg.event_type !== 'DEVICE_STATE_UPDATE' && msg.event_type !== 'BOTTOM_CAMERA_FRAME' && - msg.event_type !== 'TEMPERATURE_UPDATE') { + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { handleFullEvent({ event_type: msg.event_type, data: msg.data, diff --git a/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py new file mode 100644 index 00000000..20271403 --- /dev/null +++ b/tests/test_lightsheet_event.py @@ -0,0 +1,22 @@ +"""Tests for LIGHTSHEET_FRAME event type""" + +from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + + +def test_lightsheet_frame_event_exists(): + """LIGHTSHEET_FRAME event type should exist with correct name""" + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + + +def test_lightsheet_frame_excluded_from_history(): + """LIGHTSHEET_FRAME should be excluded from event history (high-volume telemetry)""" + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + + +def test_lightsheet_frame_publishes_to_subscriber(): + """Publishing LIGHTSHEET_FRAME should reach subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] From 0ec20c2c7ebe623702ac09244f7df7c4131ca5ab Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:37:27 +0530 Subject: [PATCH 086/200] feat(manual-mode): device-layer lightsheet live streamer (continuous sequence acquisition) --- gently/hardware/dispim/device_layer.py | 210 ++++++++++++++++++++++++- tests/test_lightsheet_streamer.py | 87 ++++++++++ 2 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 tests/test_lightsheet_streamer.py diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 55e8225d..c1331904 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -168,6 +168,16 @@ def __init__( self._cam_target_max_dim: int = 360 # ~360px thumbnail self._cam_jpeg_quality: int = 55 + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows + self._ls_target_max_dim: int = 512 + self._ls_jpeg_quality: int = 70 + self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} + self._ls_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure + # Plans that hold MMCore for long performance-critical work. # Anything in this set runs with state polling paused. self._heavy_plans = frozenset( @@ -845,7 +855,9 @@ def _capture_bottom_frame_sync(self) -> np.ndarray | None: logger.debug("Bottom-camera grab failed: %s", exc) return None - def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: + def _encode_frame_for_stream( + self, img: np.ndarray, max_dim: int | None = None, quality: int | None = None + ) -> dict[str, Any] | None: """Downsample + auto-contrast + JPEG-encode a uint16 frame for SSE. Optimised for streaming throughput: @@ -854,6 +866,10 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: full image — np.partition on the subsample is O(n) and avoids sorting ~120K pixels every frame * JPEG quality 55 (visually fine at thumbnail size) + + ``max_dim`` and ``quality`` default to the ``_cam_*`` instance values, + allowing callers (e.g. the lightsheet streamer) to pass different + settings without duplicating the encoder. """ if img is None or img.size == 0: return None @@ -865,9 +881,12 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: logger.warning("Cannot encode frame — OpenCV unavailable: %s", exc) return None + target_max_dim = max_dim if max_dim is not None else self._cam_target_max_dim + jpeg_quality = quality if quality is not None else self._cam_jpeg_quality + h, w = img.shape[:2] # Stride slicing — no interpolation, just take every Nth pixel. - factor = max(1, max(h, w) // self._cam_target_max_dim) + factor = max(1, max(h, w) // target_max_dim) small = img[::factor, ::factor] # Auto-contrast off a small random sample. Robust to hot pixels @@ -889,7 +908,7 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: scale = 255.0 / (hi - lo) small = np.clip((small.astype(np.float32) - lo) * scale, 0, 255).astype(np.uint8) - ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, self._cam_jpeg_quality]) + ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]) if not ok: return None b64 = base64.b64encode(jpeg.tobytes()).decode("ascii") @@ -953,6 +972,187 @@ async def _broadcast_camera(self, payload: dict[str, Any]): except ValueError: pass + # ========================================================================= + # Lightsheet (SPIM) Live Streamer — continuous sequence acquisition + # ========================================================================= + + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + p = self._ls_params + scanner = self.devices.get("scanner") + piezo = self.devices.get("piezo") + if scanner is not None: + try: + scanner.set_spim_state("Idle") + except Exception: + pass + scanner.sa_offset_y.setPosition(float(p["galvo"])) + if piezo is not None: + try: + piezo.set_spim_state("Idle") + except Exception: + pass + piezo.setPosition(float(p["piezo"])) + + def _ensure_lightsheet_sequence_sync(self) -> None: + """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + core = self.system.core + cam = self.devices.get("camera") + if cam is None: + raise RuntimeError("No lightsheet camera configured") + p = self._ls_params + need_restart = ( + not self._ls_seq_started + or self._ls_applied.get("exposure") != p["exposure"] + ) + if need_restart: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + + def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + try: + from gently.hardware.dispim.devices.acquisition import _safe_obtain + except (ImportError, AttributeError): + _safe_obtain = None + core = self.system.core + img = core.getLastImage() + if _safe_obtain is not None: + try: + img = _safe_obtain(img) + except Exception: + pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} + + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = ( + self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality) + if img is not None + else None + ) + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload: dict[str, Any]): + if not self._ls_subscribers: + return + dead: list[asyncio.Queue] = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait() + q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: + self._ls_subscribers.remove(q) + except ValueError: + pass + + async def handle_lightsheet_stream(self, request): + """GET /api/lightsheet/stream — SSE of base64-JPEG frames from SPIM camera. + + The streamer task spins up on first connect and exits when the last + subscriber leaves. Uses continuous sequence acquisition for best FPS. + """ + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task( + self._lightsheet_streamer(), name="lightsheet-streamer" + ) + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if payload is None: + break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: + self._ls_subscribers.remove(queue) + except ValueError: + pass + return response + + async def handle_lightsheet_params(self, request): + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure. + + Body: {"galvo": float, "piezo": float, "exposure": float} (all optional). + Galvo/piezo apply on the next grab; exposure change triggers sequence restart. + """ + body = await request.json() + for k in ("galvo", "piezo", "exposure"): + if k in body and body[k] is not None: + self._ls_params[k] = float(body[k]) + return web.json_response({"params": self._ls_params}) + # ========================================================================= # MMCore Push Callbacks # ========================================================================= @@ -2806,6 +3006,10 @@ async def on_start(self): # Bottom-camera live stream (subscriber-gated, off when nobody listens) self._app.router.add_get("/api/bottom_camera/stream", self.handle_bottom_camera_stream) + # Lightsheet (SPIM) live stream — continuous sequence acquisition + self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) + self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) + # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py new file mode 100644 index 00000000..78b79c4b --- /dev/null +++ b/tests/test_lightsheet_streamer.py @@ -0,0 +1,87 @@ +# tests/test_lightsheet_streamer.py +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_layer +for _mod in ("bluesky", "bluesky.run_engine", "ophyd", "ophyd.status", + "pymmcore", "gently.hardware.console_ui"): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +# Patch bluesky.RunEngine specifically +import bluesky as _bs +_bs.RunEngine = MagicMock(name="RunEngine") + +import asyncio, numpy as np, pytest +from gently.hardware.dispim.device_layer import DeviceLayerServer + + +class FakeCore: + def __init__(self): + self.running = False; self.exposure = None; self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0; self.stopped = 0 + def setCameraDevice(self, n): self.cam = n + def getCameraDevice(self): return self.cam + def setExposure(self, n, ms): self.exposure = ms + def startContinuousSequenceAcquisition(self, interval): self.running = True; self.started += 1 + def stopSequenceAcquisition(self): self.running = False; self.stopped += 1 + def isSequenceRunning(self): return self.running + def getLastImage(self): return self._frame + + +class FakeAxis: + def __init__(self): self.pos = None + def setPosition(self, v): self.pos = v + + +class FakeScanner: + def __init__(self): self.sa_offset_y = FakeAxis(); self.name = "Scanner"; self.state = None + def set_spim_state(self, s): self.state = s + + +class FakePiezo(FakeAxis): + def __init__(self): super().__init__(); self.name = "Piezo"; self.state = None + def set_spim_state(self, s): self.state = s + + +def _make_dl(): + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = { + "camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), + "piezo": FakePiezo(), + } + return dl + + +@pytest.mark.asyncio +async def test_grab_parks_and_peeks(monkeypatch): + dl = _make_dl() + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + dl._ls_seq_started = False; dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + + +@pytest.mark.asyncio +async def test_exposure_change_restarts_sequence(): + dl = _make_dl() + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + dl._ls_seq_started = False; dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 From 2c2cc9514af6d14786b773725dd5336bccd7ec7d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:44:49 +0530 Subject: [PATCH 087/200] feat(manual-mode): client stream_lightsheet + set_lightsheet_live_params --- gently/hardware/dispim/client.py | 50 ++++++++++++++++++++++++++++++++ tests/test_lightsheet_client.py | 25 ++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_lightsheet_client.py diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 56a9a33c..3c1aced2 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -954,6 +954,56 @@ async def stream_bottom_camera(self, timeout: float | None = None): except Exception as exc: logger.warning("Malformed bottom-camera SSE payload skipped: %s", exc) + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout( + total=None, + sock_read=timeout, + sock_connect=10.0, + ) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params( + self, galvo=None, piezo=None, exposure=None + ) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: + body["galvo"] = float(galvo) + if piezo is not None: + body["piezo"] = float(piezo) + if exposure is not None: + body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) + async def set_camera_led_mode(self, use_led: bool = False) -> dict: """Enable/disable automatic LED for bottom camera captures.""" return await self._api_post("/api/camera/led_mode", {"use_led": use_led}) diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py new file mode 100644 index 00000000..75c0d392 --- /dev/null +++ b/tests/test_lightsheet_client.py @@ -0,0 +1,25 @@ +import pytest +from gently.hardware.dispim.client import DiSPIMMicroscope + + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"params": body} + + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + + assert inspect.isasyncgenfunction(m.stream_lightsheet) From 8b86000fdeb6cb7470e9138f24736e8cb1b0882f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:48:37 +0530 Subject: [PATCH 088/200] feat(manual-mode): LightSheetStreamMonitor bridge + agent wiring --- gently/app/agent.py | 18 ++++++ gently/app/lightsheet_monitor.py | 98 ++++++++++++++++++++++++++++++++ tests/test_lightsheet_monitor.py | 32 +++++++++++ 3 files changed, 148 insertions(+) create mode 100644 gently/app/lightsheet_monitor.py create mode 100644 tests/test_lightsheet_monitor.py diff --git a/gently/app/agent.py b/gently/app/agent.py index afe2e649..02ec700a 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -215,6 +215,8 @@ def __init__( # Opt-in bottom-camera stream bridge — created when viz starts, but # left unstarted until the operator clicks "Start camera" in the UI. self.bottom_camera_monitor = None + # Opt-in lightsheet stream bridge — same lifecycle as bottom_camera_monitor. + self.lightsheet_monitor = None # ===== Create delegate managers ===== @@ -859,6 +861,16 @@ async def start_viz_server( logger.warning(f"Failed to construct bottom-camera monitor: {e}") self.bottom_camera_monitor = None + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None + async def stop_viz_server(self): """Stop the visualization server if running.""" if self.bottom_camera_monitor is not None: @@ -867,6 +879,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop bottom-camera monitor") self.bottom_camera_monitor = None + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None if self.device_state_monitor is not None: try: await self.device_state_monitor.stop() diff --git a/gently/app/lightsheet_monitor.py b/gently/app/lightsheet_monitor.py new file mode 100644 index 00000000..78b7ca39 --- /dev/null +++ b/gently/app/lightsheet_monitor.py @@ -0,0 +1,98 @@ +""" +LightSheetStreamMonitor — bridges the device-layer lightsheet SSE stream +onto the EventBus as ``LIGHTSHEET_FRAME`` events. + +Modelled on :class:`gently.app.bottom_camera_monitor.BottomCameraStreamMonitor`, +with the same opt-in semantics: streaming is not started on agent boot — only +when the operator enables it explicitly from the UI. The agent's start/stop +methods are the only path that connects/disconnects this monitor. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +if TYPE_CHECKING: + from gently.hardware.dispim.client import DiSPIMMicroscope + +logger = logging.getLogger(__name__) + + +class LightSheetStreamMonitor(Service): + """Consumes the lightsheet SSE stream and republishes frames on the bus. + + The browser receives frames via the viz server's wildcard subscription; + no additional plumbing is needed at the agent layer beyond starting the + bridge when the operator asks for it. + """ + + def __init__( + self, + microscope: DiSPIMMicroscope, + reconnect_delay_sec: float = 2.0, + ): + super().__init__(name="lightsheet-monitor", service_type="bridge") + self.microscope = microscope + self.reconnect_delay_sec = reconnect_delay_sec + self._task: asyncio.Task | None = None + self._stop_requested = False + self._last_frame_ts: float | None = None + + @property + def running(self) -> bool: + return self._task is not None and not self._task.done() + + async def on_start(self): + if self.running: + return + self._stop_requested = False + self._task = asyncio.create_task(self._run(), name="lightsheet-monitor") + logger.info("LightSheetStreamMonitor: started") + + async def on_stop(self): + self._stop_requested = True + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("LightSheetStreamMonitor: stopped") + + async def _run(self): + bus = get_event_bus() + while not self._stop_requested: + try: + logger.info("LightSheetStreamMonitor: opening stream") + async for payload in self.microscope.stream_lightsheet(): + if self._stop_requested: + break + self._last_frame_ts = payload.get("t") + try: + bus.publish( + event_type=EventType.LIGHTSHEET_FRAME, + data=payload, + source="lightsheet-monitor", + ) + except Exception: + logger.exception("Failed to publish frame") + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "LightSheetStreamMonitor: stream ended (%s) — reconnecting in %.1fs", + exc, + self.reconnect_delay_sec, + ) + if self._stop_requested: + break + try: + await asyncio.sleep(self.reconnect_delay_sec) + except asyncio.CancelledError: + raise diff --git a/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py new file mode 100644 index 00000000..f5cdf797 --- /dev/null +++ b/tests/test_lightsheet_monitor.py @@ -0,0 +1,32 @@ +""" +Tests for LightSheetStreamMonitor. + +Mirrors test_bottom_camera_monitor.py structure; uses a FakeScope that yields +three synthetic frames so the test never touches the device layer. +""" + +import asyncio +import pytest + +from gently.core.event_bus import EventType, get_event_bus +from gently.app.lightsheet_monitor import LightSheetStreamMonitor + + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False From d641fa401e1d06d60d4e453aa134d1a1dcef3f91 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:53:12 +0530 Subject: [PATCH 089/200] feat(manual-mode): require_control proxy routes for lightsheet live + illumination + acquire --- gently/ui/web/routes/data.py | 175 +++++++++++++++++++++++++ tests/test_lightsheet_routes.py | 218 ++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 tests/test_lightsheet_routes.py diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index a609526a..4d9f5be8 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -412,6 +412,181 @@ async def set_temperature(payload: dict = Body(...)): # noqa: B008 "waited": res.get("waited", False), } + # ------------------------------------------------------------------ + # Lightsheet live stream + # ------------------------------------------------------------------ + + @router.get("/api/devices/lightsheet/live/status") + async def get_lightsheet_live_status(): + """Return whether the lightsheet live stream bridge is running.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + return { + "available": monitor is not None, + "streaming": bool(monitor and monitor.running), + "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None, + } + + @router.post( + "/api/devices/lightsheet/live/start", + dependencies=[Depends(require_control)], + ) + async def start_lightsheet_live_stream(): + """Start the lightsheet live stream bridge. + + Idempotent — calling start() while already running is a no-op. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + raise HTTPException( + status_code=503, + detail="Lightsheet monitor not initialised (agent or microscope not ready)", + ) + try: + await monitor.start() + except Exception as exc: + logger.exception("Failed to start lightsheet monitor") + raise HTTPException(status_code=500, detail=f"start failed: {exc}") from exc + return {"streaming": monitor.running} + + @router.post( + "/api/devices/lightsheet/live/stop", + dependencies=[Depends(require_control)], + ) + async def stop_lightsheet_live_stream(): + """Stop the lightsheet live stream bridge. Idempotent.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + return {"streaming": False} + try: + await monitor.stop() + except Exception as exc: + logger.exception("Failed to stop lightsheet monitor") + raise HTTPException(status_code=500, detail=f"stop failed: {exc}") from exc + return {"streaming": False} + + # ------------------------------------------------------------------ + # Lightsheet live params + # ------------------------------------------------------------------ + + @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) + async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + """Forward galvo/piezo/exposure params to the device-layer lightsheet streamer.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_lightsheet_live_params( + galvo=payload.get("galvo"), piezo=payload.get("piezo"), + exposure=payload.get("exposure")) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + # ------------------------------------------------------------------ + # LED / laser / camera + # ------------------------------------------------------------------ + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + """Set the LED shutter state. Body: {"state": "Open"|"Closed"}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + logger.exception("LED set command failed") + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + """Force the 488 nm laser off. + + Confirmed signature: set_laser_power(wavelength: int, pct: float). + Calls set_laser_power(488, 0) to drive the 488 nm line to 0 %. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_power(488, 0) + except Exception as exc: + logger.exception("Laser off command failed") + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) + async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 + """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + logger.exception("Camera LED mode command failed") + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Stage + # ------------------------------------------------------------------ + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + """Move the stage to an absolute XY position. Body: {"x": float, "y": float}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: + raise HTTPException(status_code=400, detail="x and y required") + except Exception as exc: + logger.exception("Stage move command failed") + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Acquisition + # ------------------------------------------------------------------ + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + """Trigger a burst acquisition. Body: {frames, mode, num_slices, exposure_ms}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), + mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0)), + ) + except Exception as exc: + logger.exception("Burst acquisition failed") + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + """Trigger a volume acquisition. Body: {num_slices, exposure_ms}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0)), + ) + except Exception as exc: + logger.exception("Volume acquisition failed") + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py new file mode 100644 index 00000000..ed68552f --- /dev/null +++ b/tests/test_lightsheet_routes.py @@ -0,0 +1,218 @@ +"""Tests for Task 5 lightsheet browser proxy routes. + +Verifies: + - live/start, live/stop, live/status — mirror bottom_camera routes + - live/params — forwards to client.set_lightsheet_live_params + - led/set, laser/off, camera/led_mode, stage/move + - acquire/burst, acquire/volume + - require_control gate (403 without override) +""" +from unittest.mock import MagicMock, AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.data import create_router +import gently.ui.web.auth as auth + + +def _app(client=None, monitor=None): + """Build a TestClient with the lightsheet routes wired up. + + Overrides require_control so that the TestClient (host="testclient") + always gets the control role — isolates route logic from auth. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI() + app.include_router(create_router(server)) + # TestClient host is "testclient" (not loopback) → override to force CONTROL + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# live start / stop / status +# --------------------------------------------------------------------------- + +def test_live_status_no_monitor(): + """GET status returns available=False when monitor is None.""" + r = _app(monitor=None).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + assert r.json()["available"] is False + + +def test_live_status_with_monitor(): + monitor = MagicMock() + monitor.running = True + monitor._last_frame_ts = "2026-01-01T00:00:00" + r = _app(monitor=monitor).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["streaming"] is True + + +def test_live_start_requires_monitor(): + """POST start with no monitor → 503.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 + + +def test_live_start_calls_monitor_start(): + monitor = MagicMock() + monitor.start = AsyncMock() + monitor.running = True + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/start") + assert r.status_code == 200 + monitor.start.assert_awaited_once() + + +def test_live_stop_no_monitor_returns_200(): + """POST stop with no monitor is idempotent — returns 200 streaming=False.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + assert r.json()["streaming"] is False + + +def test_live_stop_calls_monitor_stop(): + monitor = MagicMock() + monitor.stop = AsyncMock() + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + monitor.stop.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# live/params +# --------------------------------------------------------------------------- + +def test_live_params_forwards(): + client = MagicMock() + client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}, + ) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with( + galvo=1.0, piezo=40.0, exposure=20.0 + ) + + +def test_live_params_no_client_503(): + server = MagicMock() + server.agent_bridge.agent.client = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# led/set +# --------------------------------------------------------------------------- + +def test_led_set_forwards(): + client = MagicMock() + client.set_led = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/led/set", json={"state": "Open"}) + assert r.status_code == 200 + client.set_led.assert_awaited_once_with("Open") + + +# --------------------------------------------------------------------------- +# laser/off — confirmed signature: set_laser_power(wavelength: int, pct: float) +# --------------------------------------------------------------------------- + +def test_laser_off_calls_set_laser_power_488_0(): + """laser/off must call set_laser_power(488, 0) to force 488 nm line off.""" + client = MagicMock() + client.set_laser_power = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/laser/off") + assert r.status_code == 200 + client.set_laser_power.assert_awaited_once_with(488, 0) + + +# --------------------------------------------------------------------------- +# camera/led_mode +# --------------------------------------------------------------------------- + +def test_camera_led_mode_forwards(): + client = MagicMock() + client.set_camera_led_mode = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/camera/led_mode", json={"use_led": True}) + assert r.status_code == 200 + client.set_camera_led_mode.assert_awaited_once_with(True) + + +# --------------------------------------------------------------------------- +# stage/move +# --------------------------------------------------------------------------- + +def test_stage_move_forwards(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 100.0, "y": 200.0}) + assert r.status_code == 200 + client.move_to_position.assert_awaited_once_with(100.0, 200.0) + + +def test_stage_move_missing_xy_400(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 1.0}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# acquire/burst +# --------------------------------------------------------------------------- + +def test_acquire_burst_forwards(): + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}, + ) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + client.acquire_burst.assert_awaited_once_with( + frames=60, mode="1hz", num_slices=1, exposure_ms=5.0 + ) + + +# --------------------------------------------------------------------------- +# acquire/volume +# --------------------------------------------------------------------------- + +def test_acquire_volume_forwards(): + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True, "request_id": "v1"}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={"num_slices": 50, "exposure_ms": 10.0}, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with(num_slices=50, exposure_ms=10.0) + + +# --------------------------------------------------------------------------- +# require_control gate — 403 WITHOUT override +# --------------------------------------------------------------------------- + +def test_require_control_gate_403(): + """Without the dependency override, TestClient host is not loopback → 403.""" + server = MagicMock() + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Do NOT override require_control here + r = TestClient(app).post("/api/devices/lightsheet/live/start") + assert r.status_code == 403 From 84a659dc53421d5f88cf1b6a3796fd41ee0d6708 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:00:34 +0530 Subject: [PATCH 090/200] fix(manual-mode): laser/off uses Laser ALL OFF config + expose laser presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace set_laser_power(488, 0) with set_laser_config("ALL OFF") in the laser/off proxy route so the PLogic gate turns off every laser line, not just the 488 nm setpoint (spec §2.7 brightfield safety). Add device-layer handlers handle_set_laser_config / handle_get_laser_configs (POST /api/laser/config, GET /api/laser/configs) calling DiSPIMLightSource.set() / ._get_available_configs() on the laser_control device (group_name="Laser"). Wire matching client methods set_laser_config / get_laser_configs. Add unguarded GET /api/devices/laser/configs proxy route. --- gently/hardware/dispim/client.py | 23 +++++++++ gently/hardware/dispim/device_layer.py | 66 ++++++++++++++++++++++++++ gently/ui/web/routes/data.py | 26 ++++++++-- tests/test_lightsheet_client.py | 37 +++++++++++++++ tests/test_lightsheet_routes.py | 45 ++++++++++++++++-- 5 files changed, 188 insertions(+), 9 deletions(-) diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 3c1aced2..1437a8a8 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -811,6 +811,29 @@ async def get_laser_power(self, wavelength: int) -> dict: except Exception as e: return {"success": False, "error": str(e)} + async def set_laser_config(self, config_name: str) -> dict: + """Apply a Laser config-group preset (e.g. "ALL OFF"). + + Hits ``POST /api/laser/config`` — direct, no Bluesky queue. + Use ``"ALL OFF"`` to gate every laser line off via the PLogic + OutputChannel; other presets from the MM config group are also + accepted (e.g. ``"488 only"``, ``"561 only"``). + + Parameters + ---------- + config_name : str + Exact preset name from the Laser config group. + """ + return await self._api_post("/api/laser/config", {"config": config_name}) + + async def get_laser_configs(self) -> dict: + """List available Laser config-group presets. + + Hits ``GET /api/laser/configs`` — returns ``{"configs": [...]}`` + with the preset names from the MM Laser config group. + """ + return await self._api_get("/api/laser/configs") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index c1331904..6dc97dbb 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -1995,6 +1995,70 @@ async def handle_get_light_source_power(self, request): status=500, ) + async def handle_set_laser_config(self, request): + """POST /api/laser/config — apply a Laser config-group preset. + + Body: {"config": } + Calls light_source.set(config_name) which maps to + core.setConfig(group_name, config_name) + waitForConfig. + """ + try: + data = await request.json() + config_name = data.get("config") + if not config_name: + return web.json_response( + {"success": False, "error": "missing 'config' field"}, + status=400, + ) + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + try: + light_source.set(config_name) + except Exception as e: + return web.json_response( + {"success": False, "error": str(e)}, + status=400, + ) + return web.json_response({"success": True, "config": config_name}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + + async def handle_get_laser_configs(self, request): + """GET /api/laser/configs — list available Laser config-group presets.""" + try: + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + configs = light_source._get_available_configs() + return web.json_response({"configs": configs}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -2987,6 +3051,8 @@ async def on_start(self): self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure) self._app.router.add_post("/api/light_source/power", self.handle_set_light_source_power) self._app.router.add_get("/api/light_source/power", self.handle_get_light_source_power) + self._app.router.add_post("/api/laser/config", self.handle_set_laser_config) + self._app.router.add_get("/api/laser/configs", self.handle_get_laser_configs) self._app.router.add_get("/api/plan_log", self.handle_get_plan_log) self._app.router.add_post("/session/configure", self.handle_session_configure) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 4d9f5be8..625fb449 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -507,20 +507,38 @@ async def led_set(payload: dict = Body(...)): # noqa: B008 @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) async def laser_off(): - """Force the 488 nm laser off. + """Gate ALL laser lines off via the Laser config group "ALL OFF" preset. - Confirmed signature: set_laser_power(wavelength: int, pct: float). - Calls set_laser_power(488, 0) to drive the 488 nm line to 0 %. + Uses setConfig("Laser", "ALL OFF") which drives the PLogic + OutputChannel to "none of outputs 5-8" — this gates every line + (488, 561, 405, 637) off, not just the 488 nm setpoint. + Required for safe brightfield live-view (spec §2.7). """ client = _resolve_client() if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") try: - return await client.set_laser_power(488, 0) + return await client.set_laser_config("ALL OFF") except Exception as exc: logger.exception("Laser off command failed") raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + @router.get("/api/devices/laser/configs") + async def laser_configs(): + """Return the available Laser config-group presets from the device layer. + + No require_control — read-only status route, mirrors GET status + routes like room_light/status and temperature/status. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_laser_configs() + except Exception as exc: + logger.exception("Laser configs fetch failed") + raise HTTPException(status_code=502, detail=f"laser configs failed: {exc}") from exc + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}.""" diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py index 75c0d392..cf4a9538 100644 --- a/tests/test_lightsheet_client.py +++ b/tests/test_lightsheet_client.py @@ -23,3 +23,40 @@ def test_stream_lightsheet_is_async_generator(): import inspect assert inspect.isasyncgenfunction(m.stream_lightsheet) + + +# --------------------------------------------------------------------------- +# set_laser_config / get_laser_configs +# --------------------------------------------------------------------------- + +async def test_set_laser_config_posts_correct_path_and_body(): + """set_laser_config posts to /api/laser/config with {"config": }.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"success": True, "config": body["config"]} + + m._api_post = fake_post + res = await m.set_laser_config("ALL OFF") + assert sent["path"] == "/api/laser/config" + assert sent["body"] == {"config": "ALL OFF"} + assert res["success"] is True + assert res["config"] == "ALL OFF" + + +async def test_get_laser_configs_gets_correct_path(): + """get_laser_configs GETs /api/laser/configs.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + fetched = {} + + async def fake_get(path): + fetched["path"] = path + return {"configs": ["488 only", "ALL OFF"]} + + m._api_get = fake_get + res = await m.get_laser_configs() + assert fetched["path"] == "/api/laser/configs" + assert res == {"configs": ["488 only", "ALL OFF"]} diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py index ed68552f..4d3cecd9 100644 --- a/tests/test_lightsheet_routes.py +++ b/tests/test_lightsheet_routes.py @@ -127,16 +127,51 @@ def test_led_set_forwards(): # --------------------------------------------------------------------------- -# laser/off — confirmed signature: set_laser_power(wavelength: int, pct: float) +# laser/off — spec §2.7: must use Laser ALL OFF config, not power setpoint # --------------------------------------------------------------------------- -def test_laser_off_calls_set_laser_power_488_0(): - """laser/off must call set_laser_power(488, 0) to force 488 nm line off.""" +def test_laser_off_calls_set_laser_config_all_off(): + """laser/off must call set_laser_config("ALL OFF") to gate every line off.""" client = MagicMock() - client.set_laser_power = AsyncMock(return_value={"success": True}) + client.set_laser_config = AsyncMock(return_value={"success": True}) r = _app(client=client).post("/api/devices/laser/off") assert r.status_code == 200 - client.set_laser_power.assert_awaited_once_with(488, 0) + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# laser/configs — unguarded GET, no require_control +# --------------------------------------------------------------------------- + +def test_laser_configs_forwards_to_client(): + """GET laser/configs must forward to client.get_laser_configs.""" + client = MagicMock() + client.get_laser_configs = AsyncMock( + return_value={"configs": ["488 only", "561 only", "ALL OFF", "ALL ON"]} + ) + r = _app(client=client).get("/api/devices/laser/configs") + assert r.status_code == 200 + assert r.json()["configs"] == ["488 only", "561 only", "ALL OFF", "ALL ON"] + client.get_laser_configs.assert_awaited_once() + + +def test_laser_configs_no_require_control(): + """GET laser/configs is unguarded — available even without control override.""" + from fastapi import FastAPI + from gently.ui.web.routes.data import create_router + + server = MagicMock() + client_mock = MagicMock() + client_mock.get_laser_configs = AsyncMock(return_value={"configs": ["ALL OFF"]}) + server.agent_bridge.agent.client = client_mock + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Deliberately do NOT override require_control — route must not need it + from fastapi.testclient import TestClient + + r = TestClient(app).get("/api/devices/laser/configs") + assert r.status_code == 200 # --------------------------------------------------------------------------- From 9917e12a46e4a8efa696e7126c61224d263527bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:11:46 +0530 Subject: [PATCH 091/200] =?UTF-8?q?feat(manual-mode):=20Manual=20view=20?= =?UTF-8?q?=E2=80=94=20SPIM=20live=20canvas,=20galvo/piezo/exposure,=20ill?= =?UTF-8?q?umination,=20acquire,=20temp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/static/css/main.css | 257 +++++++++++++++++ gently/ui/web/static/js/devices.js | 445 ++++++++++++++++++++++++++++- gently/ui/web/templates/index.html | 119 ++++++++ 3 files changed, 815 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 36124bd9..4c04716e 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10550,3 +10550,260 @@ body.modal-open { opacity: 0.5; cursor: not-allowed; } + +/* ========================================================================= + Manual view — lightsheet live panel + control rail + ========================================================================= */ + +/* Two-column layout: image stage (flex-grow) + fixed control rail */ +.ls-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 1rem; + font-family: 'Inter Tight', system-ui, sans-serif; +} + +/* Left column — live image stage */ +.ls-image-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.ls-stage-head { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +/* The stage itself reuses .devices-camera-stage aspect + overflow */ +.ls-stage { + flex: 1; + min-height: 280px; + /* override aspect-ratio: let the flex column stretch it */ + aspect-ratio: unset; +} + +/* Right column — control rail */ +.ls-rail { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; + overflow-y: auto; + padding: 0.1rem 0 0.5rem; +} + +/* Control group block */ +.ls-group { + display: flex; + flex-direction: column; + gap: 0.32rem; + padding: 0.55rem 0.65rem; + background: var(--map-overlay-bg); + border: 1px solid var(--map-overlay-edge); + border-radius: 5px; +} + +.ls-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +/* Generic inline row */ +.ls-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Slider + number inline */ +.ls-slider-row { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.ls-slider { + flex: 1; + min-width: 0; + height: 3px; + accent-color: var(--map-accent); + cursor: pointer; +} + +.ls-number { + width: 64px; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + border-radius: 4px; + padding: 0.2rem 0.35rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.ls-number-sm { + width: 50px; +} + +.ls-number:focus { + outline: none; + border-color: var(--map-accent); +} + +.ls-unit { + font-family: 'Inter Tight', system-ui, sans-serif; + font-size: 0.62rem; + color: var(--map-ink-mute); + flex-shrink: 0; +} + +/* Illumination button rows */ +.ls-btn-row { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.ls-illum-btn { + flex: 1; + min-width: 0; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.4rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ls-illum-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-illum-btn--active { + background: rgba(34, 211, 238, 0.14); + border-color: var(--map-accent); + color: var(--map-accent); +} + +/* Laser-off indicator */ +.ls-laser-indicator { + font-size: 0.6rem; + color: var(--map-ink-mute); + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.1rem; +} + +.ls-laser-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--map-ink-mute); + flex-shrink: 0; +} + +/* Temperature setpoint button */ +.ls-set-btn { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.ls-set-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); +} + +/* Temperature mini-graph */ +.ls-tempgraph { + min-height: 48px; + margin-top: 0.25rem; +} + +/* Acquire buttons */ +.ls-acquire-btn { + flex: 1; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.35rem 0.5rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.ls-acquire-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-acquire-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Last-captured ref card */ +.ls-lastcap { + margin-top: 0.35rem; + padding: 0.35rem 0.45rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ls-lastcap[hidden] { display: none; } + +.ls-lastcap-label { + font-size: 0.58rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +.ls-lastcap-ref { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + color: var(--map-ink); + word-break: break-all; +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a56f0015..57aa3bd0 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -14,7 +14,7 @@ */ const DevicesManager = (function () { const STALE_AFTER_MS = 4000; - const VIEWS = ['map', 'details', 'optical3d']; + const VIEWS = ['map', 'details', 'optical3d', 'manual']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -72,6 +72,34 @@ const DevicesManager = (function () { const _CAM_ZOOM_MAX = 8; const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch + // Lightsheet live panel DOM + state (Manual view) + let _lsToggle, _lsImg, _lsPlaceholder, _lsLed, _lsMeta, _lsStage; + let _lsStreaming = false; + let _lsLastFrameTs = 0; + let _lsHasFrame = false; + let _lsStaleTimer = null; + const _LS_FPS_WINDOW = 12; + let _lsFrameTimes = []; + + // Lightsheet zoom / pan (mirrors camera zoom/pan) + let _lsZoom = 1; + let _lsTx = 0; + let _lsTy = 0; + let _lsPanLast = null; + + // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params + let _lsGalvo = 0; + let _lsPiezo = 50; + let _lsExposure = 50; + let _lsParamTimer = null; + + // Lightsheet control inputs (rail) + let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; + let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsCamLedOn = false; + let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; + let _lsTempInput, _lsTempSet; + // Room-light toggle (header). Drives the SwitchBot Bot that switches the // diSPIM room light. State is the bot's cached on/off; hidden until the // device layer reports the accessory is configured. @@ -151,6 +179,29 @@ const DevicesManager = (function () { _camLed = document.getElementById('devices-camera-led'); _camMeta = document.getElementById('devices-camera-meta'); + // Manual / lightsheet panel + _lsToggle = document.getElementById('devices-ls-toggle'); + _lsImg = document.getElementById('devices-ls-img'); + _lsPlaceholder = document.getElementById('devices-ls-placeholder'); + _lsStage = document.getElementById('devices-ls-stage'); + _lsLed = document.getElementById('devices-ls-led'); + _lsMeta = document.getElementById('devices-ls-meta'); + _lsGalvoSlider = document.getElementById('devices-ls-galvo-slider'); + _lsGalvoNum = document.getElementById('devices-ls-galvo'); + _lsPiezoSlider = document.getElementById('devices-ls-piezo-slider'); + _lsPiezoNum = document.getElementById('devices-ls-piezo'); + _lsExposureNum = document.getElementById('devices-ls-exposure'); + _lsLedOpen = document.getElementById('devices-ls-led-open'); + _lsLedClosed = document.getElementById('devices-ls-led-closed'); + _lsCamLed = document.getElementById('devices-ls-cam-led'); + _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); + _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume'); + _lsBurstBtn = document.getElementById('devices-ls-burst'); + _lsLastcap = document.getElementById('devices-ls-lastcap'); + _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); + _lsTempInput = document.getElementById('devices-ls-temp-input'); + _lsTempSet = document.getElementById('devices-ls-temp-set'); + _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); @@ -1292,6 +1343,386 @@ const DevicesManager = (function () { } } + // ===================================================================== + // Lightsheet live panel (Manual view) + // ===================================================================== + + async function toggleLightsheetStream() { + if (!_lsToggle) return; + _lsToggle.disabled = true; + try { + const endpoint = _lsStreaming + ? '/api/devices/lightsheet/live/stop' + : '/api/devices/lightsheet/live/start'; + const res = await fetch(endpoint, { method: 'POST' }); + if (!res.ok) { + const detail = await res.text(); + console.error('Lightsheet toggle failed:', detail); + if (_lsMeta) _lsMeta.textContent = `error: ${res.status}`; + return; + } + const data = await res.json(); + applyLightsheetState(!!data.streaming); + } catch (err) { + console.error('Lightsheet toggle failed:', err); + if (_lsMeta) _lsMeta.textContent = `error: ${err}`; + } finally { + _lsToggle.disabled = false; + } + } + + function applyLightsheetState(streaming) { + _lsStreaming = streaming; + if (_lsToggle) { + _lsToggle.textContent = streaming ? 'Stop' : 'Start'; + _lsToggle.classList.toggle('active', streaming); + } + if (_lsLed) { + _lsLed.classList.toggle('live', streaming); + _lsLed.classList.remove('stale'); + } + if (!streaming) { + _lsHasFrame = false; + _lsFrameTimes = []; + if (_lsImg) _lsImg.classList.remove('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = false; + if (_lsMeta) _lsMeta.textContent = 'stream off'; + if (_lsStaleTimer) { clearTimeout(_lsStaleTimer); _lsStaleTimer = null; } + resetLightsheetZoom(); + } else { + _lsFrameTimes = []; + if (_lsMeta) _lsMeta.textContent = 'waiting…'; + } + } + + function handleLightsheetFrame(payload) { + if (!payload || !payload.jpeg_b64 || !_lsImg) return; + _lsImg.src = `data:${payload.mime || 'image/jpeg'};base64,${payload.jpeg_b64}`; + if (!_lsHasFrame) { + _lsHasFrame = true; + _lsImg.classList.add('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = true; + } + const now = performance.now(); + _lsLastFrameTs = Date.now(); + _lsFrameTimes.push(now); + if (_lsFrameTimes.length > _LS_FPS_WINDOW) _lsFrameTimes.shift(); + if (_lsLed) { + _lsLed.classList.add('live'); + _lsLed.classList.remove('stale'); + } + if (_lsMeta) { + const shape = payload.shape || []; + const dims = shape.length === 2 ? `${shape[1]}×${shape[0]}` : ''; + const fps = computeLightsheetFps(); + _lsMeta.textContent = dims + ? `${dims} · ${fps != null ? fps.toFixed(1) + ' fps' : '…'}` + : (fps != null ? `${fps.toFixed(1)} fps` : 'live'); + } + scheduleLightsheetStaleCheck(); + } + + function computeLightsheetFps() { + const n = _lsFrameTimes.length; + if (n < 2) return null; + const span = _lsFrameTimes[n - 1] - _lsFrameTimes[0]; + if (span <= 0) return null; + return ((n - 1) * 1000) / span; + } + + function scheduleLightsheetStaleCheck() { + if (_lsStaleTimer) clearTimeout(_lsStaleTimer); + _lsStaleTimer = setTimeout(() => { + const age = (Date.now() - _lsLastFrameTs) / 1000; + if (_lsMeta) _lsMeta.textContent = `last frame ${age.toFixed(1)}s ago`; + if (_lsLed) _lsLed.classList.add('stale'); + }, 1500); + } + + async function syncInitialLightsheetState() { + try { + const res = await fetch('/api/devices/lightsheet/live/status'); + if (!res.ok) return; + const data = await res.json(); + applyLightsheetState(!!data.streaming); + } catch (err) { + console.debug('lightsheet status check failed:', err); + } + } + + // ---- Lightsheet zoom / pan (mirrors camera zoom/pan) ---------------- + function applyLightsheetTransform() { + if (!_lsImg) return; + _lsImg.style.transform = + `translate(${_lsTx}px, ${_lsTy}px) scale(${_lsZoom})`; + } + + function resetLightsheetZoom() { + _lsZoom = 1; + _lsTx = 0; + _lsTy = 0; + applyLightsheetTransform(); + if (_lsStage) _lsStage.classList.remove('camera-zoomed', 'camera-panning'); + } + + function clampLightsheetPan() { + if (!_lsStage) return; + const rect = _lsStage.getBoundingClientRect(); + const maxX = (rect.width * (_lsZoom - 1)) / 2; + const maxY = (rect.height * (_lsZoom - 1)) / 2; + _lsTx = Math.max(-maxX, Math.min(maxX, _lsTx)); + _lsTy = Math.max(-maxY, Math.min(maxY, _lsTy)); + } + + function onLightsheetWheel(event) { + if (!_lsStage) return; + event.preventDefault(); + const rect = _lsStage.getBoundingClientRect(); + const cx = event.clientX - rect.left - rect.width / 2; + const cy = event.clientY - rect.top - rect.height / 2; + const oldZoom = _lsZoom; + const factor = event.deltaY < 0 ? _CAM_ZOOM_STEP : 1 / _CAM_ZOOM_STEP; + const newZoom = Math.max(_CAM_ZOOM_MIN, Math.min(_CAM_ZOOM_MAX, oldZoom * factor)); + if (newZoom === oldZoom) return; + const ratio = newZoom / oldZoom; + _lsTx = cx - (cx - _lsTx) * ratio; + _lsTy = cy - (cy - _lsTy) * ratio; + _lsZoom = newZoom; + if (Math.abs(_lsZoom - 1) < 0.001) { resetLightsheetZoom(); return; } + clampLightsheetPan(); + applyLightsheetTransform(); + _lsStage.classList.add('camera-zoomed'); + } + + function onLightsheetPointerDown(event) { + if (event.button !== 0) return; + if (_lsZoom <= 1) return; + _lsPanLast = { x: event.clientX, y: event.clientY }; + try { _lsStage.setPointerCapture(event.pointerId); } catch (_) {} + _lsStage.classList.add('camera-panning'); + event.preventDefault(); + } + + function onLightsheetPointerMove(event) { + if (!_lsPanLast) return; + _lsTx += event.clientX - _lsPanLast.x; + _lsTy += event.clientY - _lsPanLast.y; + _lsPanLast = { x: event.clientX, y: event.clientY }; + clampLightsheetPan(); + applyLightsheetTransform(); + } + + function onLightsheetPointerEnd(event) { + if (!_lsPanLast) return; + _lsPanLast = null; + try { _lsStage.releasePointerCapture(event.pointerId); } catch (_) {} + if (_lsStage) _lsStage.classList.remove('camera-panning'); + } + + function onLightsheetDoubleClick(event) { + if (_lsZoom !== 1 || _lsTx !== 0 || _lsTy !== 0) { + event.preventDefault(); + resetLightsheetZoom(); + } + } + + // ---- Lightsheet live params (debounced) ----------------------------- + function postLightsheetParams() { + fetch('/api/devices/lightsheet/live/params', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }), + }).catch(err => console.debug('lightsheet params post failed:', err)); + } + + function scheduleLightsheetParamPost() { + if (_lsParamTimer) clearTimeout(_lsParamTimer); + _lsParamTimer = setTimeout(postLightsheetParams, 120); + } + + function onGalvoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsGalvo = v; + // Sync the sibling control + if (src === _lsGalvoSlider && _lsGalvoNum) _lsGalvoNum.value = v; + if (src === _lsGalvoNum && _lsGalvoSlider) _lsGalvoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onPiezoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsPiezo = v; + if (src === _lsPiezoSlider && _lsPiezoNum) _lsPiezoNum.value = v; + if (src === _lsPiezoNum && _lsPiezoSlider) _lsPiezoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onExposureInput() { + const v = parseFloat(_lsExposureNum && _lsExposureNum.value); + if (isNaN(v) || v < 1) return; + _lsExposure = v; + scheduleLightsheetParamPost(); + } + + // ---- Illumination toggles ------------------------------------------- + async function postLedPreset(preset) { + try { + await fetch('/api/devices/led/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: preset }), + }); + } catch (err) { console.debug('LED preset failed:', err); } + } + + async function toggleCamLedMode() { + _lsCamLedOn = !_lsCamLedOn; + if (_lsCamLed) { + _lsCamLed.classList.toggle('ls-illum-btn--active', _lsCamLedOn); + _lsCamLed.setAttribute('aria-pressed', _lsCamLedOn ? 'true' : 'false'); + } + try { + await fetch('/api/devices/camera/led_mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ use_led: _lsCamLedOn }), + }); + } catch (err) { console.debug('cam LED mode failed:', err); } + } + + async function toggleManualRoomLight() { + const nextState = _roomLightState === 'on' ? 'off' : 'on'; + if (_lsRoomLightBtn) { + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', nextState === 'on'); + _lsRoomLightBtn.setAttribute('aria-pressed', nextState === 'on' ? 'true' : 'false'); + } + try { + const res = await fetch('/api/devices/room_light/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: nextState }), + }); + if (res.ok) { + const data = await res.json(); + _roomLightState = data.state || nextState; + if (_lsRoomLightBtn) { + const on = _roomLightState === 'on'; + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', on); + _lsRoomLightBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + } + } + } catch (err) { console.debug('manual room light toggle failed:', err); } + } + + // ---- Acquire -------------------------------------------------------- + async function runLightsheetAcquire(mode) { + const btn = mode === 'burst' ? _lsBurstBtn : _lsSnapVolBtn; + if (btn) { btn.disabled = true; btn.textContent = 'acquiring…'; } + try { + let res; + if (mode === 'burst') { + res = await fetch('/api/devices/acquire/burst', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frames: 10, mode: 'brightfield', + num_slices: 50, exposure_ms: _lsExposure }), + }); + } else { + res = await fetch('/api/devices/acquire/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure }), + }); + } + if (!res.ok) { + console.error('acquire failed:', res.status, await res.text()); + return; + } + const data = await res.json(); + if (_lsLastcap) _lsLastcap.hidden = false; + if (_lsLastcapRef) { + _lsLastcapRef.textContent = data.volume_path || data.path || data.id || 'done'; + } + } catch (err) { + console.error('acquire error:', err); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = mode === 'burst' ? 'Burst' : 'Snap Volume'; + } + } + } + + // ---- Temperature set (rail copy, delegates to same API) ------------- + async function setLightsheetTemperature() { + if (!_lsTempInput) return; + const target = parseFloat(_lsTempInput.value); + if (isNaN(target) || target < 0 || target > 99.9) return; + try { + await fetch('/api/devices/temperature/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_c: target }), + }); + } catch (err) { console.debug('ls temp set failed:', err); } + } + + function setupManualWiring() { + if (!_lsToggle) return; + _lsToggle.addEventListener('click', toggleLightsheetStream); + applyLightsheetState(false); + + // Param controls — slider ↔ number sync + debounced POST + if (_lsGalvoSlider) _lsGalvoSlider.addEventListener('input', () => onGalvoInput(_lsGalvoSlider)); + if (_lsGalvoNum) _lsGalvoNum.addEventListener('input', () => onGalvoInput(_lsGalvoNum)); + if (_lsPiezoSlider) _lsPiezoSlider.addEventListener('input', () => onPiezoInput(_lsPiezoSlider)); + if (_lsPiezoNum) _lsPiezoNum.addEventListener('input', () => onPiezoInput(_lsPiezoNum)); + if (_lsExposureNum) _lsExposureNum.addEventListener('input', onExposureInput); + + // Illumination + if (_lsLedOpen) _lsLedOpen.addEventListener('click', () => postLedPreset('Open')); + if (_lsLedClosed) _lsLedClosed.addEventListener('click', () => postLedPreset('Closed')); + if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode); + if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); + + // Acquire + if (_lsSnapVolBtn) _lsSnapVolBtn.addEventListener('click', () => runLightsheetAcquire('volume')); + if (_lsBurstBtn) _lsBurstBtn.addEventListener('click', () => runLightsheetAcquire('burst')); + + // Temperature + if (_lsTempSet) _lsTempSet.addEventListener('click', setLightsheetTemperature); + if (_lsTempInput) { + _lsTempInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); setLightsheetTemperature(); } + }); + } + + // Zoom / pan + if (_lsStage) { + _lsStage.addEventListener('wheel', onLightsheetWheel, { passive: false }); + _lsStage.addEventListener('pointerdown', onLightsheetPointerDown); + _lsStage.addEventListener('pointermove', onLightsheetPointerMove); + _lsStage.addEventListener('pointerup', onLightsheetPointerEnd); + _lsStage.addEventListener('pointercancel', onLightsheetPointerEnd); + _lsStage.addEventListener('dblclick', onLightsheetDoubleClick); + } + + // Subscribe to LIGHTSHEET_FRAME events + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame); + } + + // Embed temperature graph in the rail + const _lsTgEl = document.getElementById('devices-ls-tempgraph'); + if (_lsTgEl && window.TemperatureGraph) { + TemperatureGraph.init(_lsTgEl, 'current'); + } + + syncInitialLightsheetState(); + } + // ===================================================================== // Room-light toggle // ===================================================================== @@ -1552,6 +1983,7 @@ const DevicesManager = (function () { cacheDom(); setupViewSwitcher(); setupCameraWiring(); + setupManualWiring(); setupRoomLight(); setupTemperature(); const _tgEl = document.getElementById('devices-temp-graph'); @@ -1577,13 +2009,14 @@ const DevicesManager = (function () { document.addEventListener('keydown', onMapKeyDown); setStatus('stale', 'waiting', 'no payload yet'); syncInitialCameraState(); - // Stop the camera stream if the tab is closed while it's running, - // so MMCore isn't held by a disconnected browser. + // Stop the camera and lightsheet streams if the tab is closed while + // running, so MMCore isn't held by a disconnected browser. window.addEventListener('beforeunload', () => { if (_camStreaming) { - try { - navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); - } catch (_) {} + try { navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); } catch (_) {} + } + if (_lsStreaming) { + try { navigator.sendBeacon('/api/devices/lightsheet/live/stop'); } catch (_) {} } }); } diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 7e1f0726..dc6718c2 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -446,6 +446,7 @@

    Device Map +

    @@ -714,6 +715,124 @@

    Properties

    + + +
    From c749223234217d0f0deda365ee750f93347f91be Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:21:51 +0530 Subject: [PATCH 092/200] fix(manual-mode): init temperature graph per-view in switchView (singleton dual-init) --- gently/ui/web/static/js/devices.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 57aa3bd0..13237d4e 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -1714,12 +1714,6 @@ const DevicesManager = (function () { ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame); } - // Embed temperature graph in the rail - const _lsTgEl = document.getElementById('devices-ls-tempgraph'); - if (_lsTgEl && window.TemperatureGraph) { - TemperatureGraph.init(_lsTgEl, 'current'); - } - syncInitialLightsheetState(); } @@ -1937,6 +1931,19 @@ const DevicesManager = (function () { if (viewName === 'optical3d' && typeof Occupancy3DManager !== 'undefined') { Occupancy3DManager.init(); } + // Initialize temperature graph for the active view. The TemperatureGraph + // is a singleton; reinit on view switch ensures only one graph target + // is live at a time. ClientEventBus.off/on in TemperatureGraph.init + // makes re-init safe (idempotent). + if (window.TemperatureGraph) { + if (viewName === 'manual') { + const el = document.getElementById('devices-ls-tempgraph'); + if (el) TemperatureGraph.init(el, 'current'); + } else { + const el = document.getElementById('devices-temp-graph'); + if (el) TemperatureGraph.init(el, 'current'); + } + } } function setupViewSwitcher() { @@ -1986,8 +1993,6 @@ const DevicesManager = (function () { setupManualWiring(); setupRoomLight(); setupTemperature(); - const _tgEl = document.getElementById('devices-temp-graph'); - if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current'); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); From 5e12b60667f80e27e8137d8470ef55169081663c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:25:26 +0530 Subject: [PATCH 093/200] docs(manual-mode): lightsheet FPS measurement protocol + transport decision gate (B1 Task 7) --- .../2026-06-28-lightsheet-fps-measurement.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md diff --git a/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md new file mode 100644 index 00000000..d1a4abd9 --- /dev/null +++ b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md @@ -0,0 +1,57 @@ +# Lightsheet live-view FPS measurement + transport decision (B1 Task 7) + +Status: **deferred to the rig** — the numbers below must be filled in on the microscope +(the streamer needs the real `pymmcore` core, SPIM camera, and rpyc transport; it cannot run +on the Linux dev box). This file is the measurement protocol + the decision gate. + +## What to measure + +With the Manual view open and lightsheet live running, record three rates: + +| Metric | Where to read it | +|---|---| +| **device grab rate** (frames peeked/encoded /s) | device-layer log / instrument `_lightsheet_streamer` | +| **delivered rate** (frames broadcast /s) | device-layer `_broadcast_lightsheet` | +| **browser paint rate** | the Manual-view FPS readout (`computeLightsheetFps`) | + +Record at two resolution/quality settings: +- default **512 px / JPEG q70** (`_ls_target_max_dim=512`, `_ls_jpeg_quality=70`) +- reduced **384 px / q60** + +Note the exposure used (the peek floor is `max(exposure, 1/30 s)`). + +## Target + +**≥ ~15 fps usable for focus** (stretch 25–30), end-to-end latency < ~150 ms. + +## Diagnosis rule (the gate) + +- **device grab < target** → limiter is exposure / readout / rpyc, **not** transport. Tune + exposure, the 512 px size, and JPEG quality. A binary transport path will NOT help — stop here. +- **device grab ≥ target but browser paint < target** → transport is the bottleneck → build the + **binary WebSocket path** (below). + +## Conditional escalation — binary WebSocket path + +Only if the diagnosis points to transport. The current path is base64-JPEG-in-JSON over SSE → +EventBus → `ConnectionManager.broadcast` (`json.dumps` + `send_text`) — `connection_manager.py` +has **no `send_bytes` path** (confirmed). Escalation, on the **agent→browser hop** (where cost +multiplies per client): + +- push raw JPEG bytes via `websocket.send_bytes(prefix + jpeg)` (a 1-byte type tag identifies a + lightsheet frame), bypassing **base64 (+33%)**, the **per-client `json.dumps`**, and the + **EventBus fan-out**; +- browser `onmessage` (binary) → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`; +- the device→agent SSE stays as-is (single consumer = the monitor, so its base64 cost is paid + once, not per browser). + +Re-measure after building; record the before/after numbers here. + +## Results (fill in on the rig) + +| setting | device grab fps | delivered fps | browser paint fps | exposure | notes | +|---|---|---|---|---|---| +| 512px/q70 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | +| 384px/q60 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | + +Decision: _TBD (transport bottleneck? build binary path Y/N)_ From 779e6c68d5a6392f74e2b528b39ff7926ace6963 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:54:32 +0530 Subject: [PATCH 094/200] fix(manual-mode): brightfield-safe + focal-plane-correct acquire, sequence-stop on kickoff, park-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: DiSPIMVolumeScanner.trigger() stops any running camera sequence before startSequenceAcquisition — prevents MMCore collision when live streamer is active (covers both acquire_single_volume_plan and burst_plan callers). park-guard: _park_lightsheet_sync now fires set_spim_state("Idle") once per stream session (not per frame) and calls setPosition only when the value changed. Cuts 4 serial round-trips per frame to 0 on steady state. _stop_lightsheet_sequence_sync resets the guard flags. I1: acquire/burst + acquire/volume proxy routes forward optional laser_config from body → client. JS runLightsheetAcquire sends laser_config:"ALL OFF" in both bodies (brightfield-safe Manual-view acquire). I2: proxy routes forward optional piezo_center + galvo_center; JS sends piezo_center:_lsPiezo and galvo_center:_lsGalvo (galvo confirmed degrees via sa_offset_y / configure_y_axis chain — same unit as live param). I3: new setLaserOff() helper calls POST /api/devices/laser/off and updates #devices-ls-laser-status from real API result. Called on switchView('manual') and on toggleLightsheetStream start path. exposure: _lsExposure default and HTML input value aligned to 20 ms (matches device-layer _ls_params default). Tests: 4 new proxy tests for laser_config+focal-plane forwarding; streamer _make_dl seeds _ls_parked/_ls_spim_idle. All 22 target tests pass. --- gently/hardware/dispim/device_layer.py | 47 ++++++++++++++----- gently/hardware/dispim/devices/acquisition.py | 8 ++++ gently/ui/web/routes/data.py | 34 +++++++++++++- gently/ui/web/static/js/devices.js | 35 ++++++++++++-- gently/ui/web/templates/index.html | 4 +- tests/test_lightsheet_routes.py | 35 ++++++++++++++ tests/test_lightsheet_streamer.py | 3 ++ 7 files changed, 148 insertions(+), 18 deletions(-) diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 6dc97dbb..0e44ebfa 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -177,6 +177,8 @@ def __init__( self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure + self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values + self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session # Plans that hold MMCore for long performance-critical work. # Anything in this set runs with state polling paused. @@ -977,22 +979,41 @@ async def _broadcast_camera(self, payload: dict[str, Any]): # ========================================================================= def _park_lightsheet_sync(self) -> None: - """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + """Park scanner galvo + imaging piezo at the current live params (static sheet). + + Guards: + - set_spim_state("Idle") fires once per stream session, not per frame + (4 serial round-trips on first call → 0 on every subsequent frame). + - setPosition fires only when the commanded value differs from the + last-applied value (no-op on steady-state frames). + """ p = self._ls_params scanner = self.devices.get("scanner") piezo = self.devices.get("piezo") + # SPIM Idle state machine: drive both devices Idle once per session. + if not self._ls_spim_idle: + if scanner is not None: + try: + scanner.set_spim_state("Idle") + except Exception: + pass + if piezo is not None: + try: + piezo.set_spim_state("Idle") + except Exception: + pass + self._ls_spim_idle = True + # setPosition only when the value changed from last-applied. if scanner is not None: - try: - scanner.set_spim_state("Idle") - except Exception: - pass - scanner.sa_offset_y.setPosition(float(p["galvo"])) + want = float(p["galvo"]) + if self._ls_parked.get("galvo") != want: + scanner.sa_offset_y.setPosition(want) + self._ls_parked["galvo"] = want if piezo is not None: - try: - piezo.set_spim_state("Idle") - except Exception: - pass - piezo.setPosition(float(p["piezo"])) + want = float(p["piezo"]) + if self._ls_parked.get("piezo") != want: + piezo.setPosition(want) + self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" @@ -1044,6 +1065,10 @@ def _stop_lightsheet_sequence_sync(self) -> None: logger.debug("stop lightsheet sequence failed", exc_info=True) self._ls_seq_started = False self._ls_applied = {} + # Reset park guard so the next stream session re-idles the state + # machine and re-parks the axes (hardware may have moved). + self._ls_spim_idle = False + self._ls_parked = {} async def _lightsheet_streamer(self): logger.info("Lightsheet streamer started") diff --git a/gently/hardware/dispim/devices/acquisition.py b/gently/hardware/dispim/devices/acquisition.py index 9b2658ef..fda839d1 100644 --- a/gently/hardware/dispim/devices/acquisition.py +++ b/gently/hardware/dispim/devices/acquisition.py @@ -188,6 +188,14 @@ def wait(): self.core.setConfig(self.laser_control.group_name, self._laser_config) self.core.waitForConfig(self.laser_control.group_name, self._laser_config) + # Defensive stop: the lightsheet live streamer may have left the + # camera in continuous acquisition. Starting a new sequence on + # an already-running camera triggers an MMCore error (and is + # thread-unsafe). Stop cleanly before reconfiguring. + if self.core.isSequenceRunning(): + self.core.stopSequenceAcquisition() + self.core.waitForDevice(self.camera.name) + # Prepare circular buffer self.core.clearCircularBuffer() buffer_capacity = self.core.getBufferTotalCapacity() diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 625fb449..cfbeb11c 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -575,16 +575,31 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) async def acquire_burst(payload: dict = Body(...)): # noqa: B008 - """Trigger a burst acquisition. Body: {frames, mode, num_slices, exposure_ms}.""" + """Trigger a burst acquisition. + + Body: {frames, mode, num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ client = _resolve_client() if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) return await client.acquire_burst( frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), num_slices=int(payload.get("num_slices", 1)), exposure_ms=float(payload.get("exposure_ms", 5.0)), + **kw, ) except Exception as exc: logger.exception("Burst acquisition failed") @@ -592,14 +607,29 @@ async def acquire_burst(payload: dict = Body(...)): # noqa: B008 @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) async def acquire_volume(payload: dict = Body(...)): # noqa: B008 - """Trigger a volume acquisition. Body: {num_slices, exposure_ms}.""" + """Trigger a volume acquisition. + + Body: {num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ client = _resolve_client() if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) return await client.acquire_volume( num_slices=int(payload.get("num_slices", 50)), exposure_ms=float(payload.get("exposure_ms", 10.0)), + **kw, ) except Exception as exc: logger.exception("Volume acquisition failed") diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 13237d4e..2de449e4 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -90,7 +90,7 @@ const DevicesManager = (function () { // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params let _lsGalvo = 0; let _lsPiezo = 50; - let _lsExposure = 50; + let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) let _lsParamTimer = null; // Lightsheet control inputs (rail) @@ -98,6 +98,7 @@ const DevicesManager = (function () { let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; let _lsCamLedOn = false; let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; + let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -199,6 +200,7 @@ const DevicesManager = (function () { _lsBurstBtn = document.getElementById('devices-ls-burst'); _lsLastcap = document.getElementById('devices-ls-lastcap'); _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); + _lsLaserStatus = document.getElementById('devices-ls-laser-status'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1347,10 +1349,27 @@ const DevicesManager = (function () { // Lightsheet live panel (Manual view) // ===================================================================== + /** Gate ALL lasers off via the Laser "ALL OFF" config-group preset. + * Updates the indicator span from the actual API result (not a static label). + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserOff() { + cacheDom(); + try { + const res = await fetch('/api/devices/laser/off', { method: 'POST' }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? 'OFF (brightfield)' : 'warning: state unknown'; + } + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser off call failed:', err); + } + } + async function toggleLightsheetStream() { if (!_lsToggle) return; _lsToggle.disabled = true; try { + const starting = !_lsStreaming; const endpoint = _lsStreaming ? '/api/devices/lightsheet/live/stop' : '/api/devices/lightsheet/live/start'; @@ -1363,6 +1382,8 @@ const DevicesManager = (function () { } const data = await res.json(); applyLightsheetState(!!data.streaming); + // Gate lasers off whenever live starts — brightfield-safe by default. + if (starting && data.streaming) setLaserOff(); } catch (err) { console.error('Lightsheet toggle failed:', err); if (_lsMeta) _lsMeta.textContent = `error: ${err}`; @@ -1627,13 +1648,19 @@ const DevicesManager = (function () { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ frames: 10, mode: 'brightfield', - num_slices: 50, exposure_ms: _lsExposure }), + num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), }); } else { res = await fetch('/api/devices/acquire/volume', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure }), + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), }); } if (!res.ok) { @@ -1944,6 +1971,8 @@ const DevicesManager = (function () { if (el) TemperatureGraph.init(el, 'current'); } } + // Entering Manual view — gate lasers off immediately (brightfield-safe). + if (viewName === 'manual') setLaserOff(); } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index dc6718c2..1b1dba8e 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -752,7 +752,7 @@

    Properties

    ms
    @@ -799,7 +799,7 @@

    Properties

    - Laser: OFF (brightfield) + Laser: OFF (brightfield)
    diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py index 4d3cecd9..0f7f172b 100644 --- a/tests/test_lightsheet_routes.py +++ b/tests/test_lightsheet_routes.py @@ -210,6 +210,7 @@ def test_stage_move_missing_xy_400(): # --------------------------------------------------------------------------- def test_acquire_burst_forwards(): + """Basic burst forwards without optional params — no laser_config/piezo/galvo in body.""" client = MagicMock() client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) r = _app(client=client).post( @@ -222,11 +223,29 @@ def test_acquire_burst_forwards(): ) +def test_acquire_burst_forwards_laser_config_and_focal_plane(): + """acquire/burst forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={"frames": 10, "mode": "brightfield", "num_slices": 50, + "exposure_ms": 20.0, "laser_config": "ALL OFF", + "piezo_center": 55.0, "galvo_center": 1.5}, + ) + assert r.status_code == 200 + client.acquire_burst.assert_awaited_once_with( + frames=10, mode="brightfield", num_slices=50, exposure_ms=20.0, + laser_config="ALL OFF", piezo_center=55.0, galvo_center=1.5, + ) + + # --------------------------------------------------------------------------- # acquire/volume # --------------------------------------------------------------------------- def test_acquire_volume_forwards(): + """Basic volume forwards without optional params.""" client = MagicMock() client.acquire_volume = AsyncMock(return_value={"success": True, "request_id": "v1"}) r = _app(client=client).post( @@ -237,6 +256,22 @@ def test_acquire_volume_forwards(): client.acquire_volume.assert_awaited_once_with(num_slices=50, exposure_ms=10.0) +def test_acquire_volume_forwards_laser_config_and_focal_plane(): + """acquire/volume forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={"num_slices": 50, "exposure_ms": 20.0, + "laser_config": "ALL OFF", "piezo_center": 55.0, "galvo_center": 1.5}, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with( + num_slices=50, exposure_ms=20.0, + laser_config="ALL OFF", piezo_center=55.0, galvo_center=1.5, + ) + + # --------------------------------------------------------------------------- # require_control gate — 403 WITHOUT override # --------------------------------------------------------------------------- diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index 78b79c4b..c36377a1 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -53,6 +53,9 @@ def _make_dl(): "scanner": FakeScanner(), "piezo": FakePiezo(), } + # Park-guard state (park-guard fix: these are checked by _park_lightsheet_sync) + dl._ls_parked = {} + dl._ls_spim_idle = False return dl From 909aacc6ad4d991f77a3ca3138aece868b5ae3bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:26:46 +0530 Subject: [PATCH 095/200] docs(tactic): spec + plan for temp-change burst tactic (C, 6 tasks) --- .../2026-06-28-temp-change-burst-tactic.md | 280 ++++++++++++++++++ ...6-06-28-temp-change-burst-tactic-design.md | 102 +++++++ 2 files changed, 382 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md create mode 100644 docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md diff --git a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md new file mode 100644 index 00000000..8c740b3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md @@ -0,0 +1,280 @@ +# Temp-Change Burst Tactic (C) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A scripted temperature-change burst protocol — brightfield bursts before a setpoint change, during the ramp (until lock), and after — launchable as an agent tool, observable on the Experiment tab. + +**Architecture:** A thin async `TimelapseOrchestrator.run_temp_change_burst_protocol` driver composing existing `BurstAcquisition` (extended to force lasers off), `set_temperature`/`get_temperature`, and brightfield primitives; new timeline EventTypes so the tactic + setpoint changes render; an agent tool that launches the driver via `asyncio.create_task`. + +**Tech Stack:** Python asyncio, the gently EventBus + TimelineManager, pytest (`asyncio_mode=auto`). + +## Global Constraints +- C composes A (temperature stamp/persistence) + B1 (`set_laser_config("ALL OFF")`). No new deps. +- Brightfield every burst: `laser_config="ALL OFF"`; lasers never left on, even on error/cancel. +- Lock contract: poll `client.get_temperature()["state"]` until `"LOCKED" in state` (the device reports `'[ SYSTEM LOCKED ]'`). +- Bursts are temperature-stamped automatically (A) and emit `BURST_START/COMPLETE` (render for free). +- New EventTypes use `auto()` (wire serializes `.name`). +- Tests: fakes for client + burst; `asyncio_mode=auto` (no decorator). Rig-deferred: real ramp timing. +- Git hygiene: stage only your files by explicit path; never `git add -A` (pre-existing untracked screenshots/mockups + uv.lock are not yours). + +--- + +### Task 1: Timeline EventTypes for the tactic + +**Files:** Modify `gently/core/event_bus.py` (3 new `auto()` members near the other domain events); Modify `gently/harness/session/timeline.py` (add 3 entries to the EventType→subtype map, near the `BURST_*` entries ~line 213-225). Test: `tests/test_temp_protocol_events.py`. + +**Interfaces:** Produces `EventType.TEMPERATURE_SETPOINT_CHANGED`, `EventType.TEMP_PROTOCOL_STARTED`, `EventType.TEMP_PROTOCOL_COMPLETED`, each mapped to a timeline subtype (`setpoint_changed`, `temp_protocol_started`, `temp_protocol_completed`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_events.py +from gently.core.event_bus import EventType + +def test_new_event_types_exist(): + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + +def test_timeline_maps_the_subtypes(): + from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src +``` +- [ ] **Step 2: run, expect FAIL** — `pytest tests/test_temp_protocol_events.py -v` +- [ ] **Step 3: implement** — in `event_bus.py`, alongside the burst events: +```python + TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline) + TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began + TEMP_PROTOCOL_COMPLETED = auto() # protocol ended +``` +In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add: +```python + EventType.TEMPERATURE_SETPOINT_CHANGED: {"category": "temperature", "event_subtype": "setpoint_changed"}, + EventType.TEMP_PROTOCOL_STARTED: {"category": "tactic", "event_subtype": "temp_protocol_started"}, + EventType.TEMP_PROTOCOL_COMPLETED: {"category": "tactic", "event_subtype": "temp_protocol_completed"}, +``` +> Confirm the real map structure (keys/value shape) from the existing `BURST_START` entry and match it exactly. +- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. +- [ ] **Step 5: commit** — `git add gently/core/event_bus.py gently/harness/session/timeline.py tests/test_temp_protocol_events.py && git commit -m "feat(tactic): timeline event types for temp-change burst protocol"` + +--- + +### Task 2: Brightfield burst — thread `laser_config` + +**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.__init__` ~line 95 add param; its `client.acquire_burst(...)` call ~line 159-168 pass it); Modify `gently/app/orchestration/timelapse.py` (`queue_burst` ~line 2058 add `laser_config=None`, pass to `BurstAcquisition`). Test: `tests/test_burst_laser_config.py`. + +**Interfaces:** Consumes `client.acquire_burst(..., laser_config=...)` (already accepts it, `client.py:642`). Produces `BurstAcquisition(..., laser_config=None)` and `queue_burst(..., laser_config=None)`. + +- [ ] **Step 1: failing test** +```python +# tests/test_burst_laser_config.py +import asyncio +from gently.app.orchestration.exclusive import BurstAcquisition + +class FakeClient: + def __init__(self): self.calls = [] + async def acquire_burst(self, **kw): self.calls.append(kw); return {"success": True, "request_id": "b1", "frames": []} + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + # the run() path forwards _laser_config into client.acquire_burst kwargs: + # (unit-level: assert the attribute + that run threads it — see note) +``` +> NOTE: `BurstAcquisition.run` needs an orchestrator with a client + embryo lookup + persistence; a full run is heavy. Assert at minimum that `__init__` stores `_laser_config` and that the `acquire_burst` call site in `run` includes `laser_config=self._laser_config` (verify by reading; optionally add a focused test that monkeypatches the embryo/persistence to capture the `acquire_burst` kwargs). Keep the test honest — if a full run is impractical, assert the attribute and add an inline source check that `laser_config=self._laser_config` appears in the `acquire_burst(...)` call. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — add `laser_config: str | None = None` to `__init__`, store `self._laser_config = laser_config`; in the `client.acquire_burst(...)` call add `laser_config=self._laser_config`. In `queue_burst`, add `laser_config: str | None = None` and pass `laser_config=laser_config` into the `BurstAcquisition(...)` construction. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts)` + +--- + +### Task 3: `wait_for_temperature_lock` helper + +**Files:** Create `gently/app/orchestration/temperature_protocol.py` (module for the helper + later the driver). Test: `tests/test_wait_for_lock.py`. + +**Interfaces:** Produces `async def wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool`. + +- [ ] **Step 1: failing test** +```python +# tests/test_wait_for_lock.py +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + +class FakeClient: + def __init__(self, states): self.states = list(states); self.calls = 0 + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1); self.calls += 1 + return {"state": self.states[i]} + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** +```python +# gently/app/orchestration/temperature_protocol.py +import asyncio, logging +logger = logging.getLogger(__name__) + +async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: + """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" + loop = asyncio.get_event_loop() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + if "LOCKED" in str(resp.get("state", "")): + return True + if loop.time() - t0 >= timeout_s: + return False + await asyncio.sleep(poll_s) +``` +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): wait_for_temperature_lock poll helper` + +--- + +### Task 4: The protocol driver + +**Files:** Modify `gently/app/orchestration/temperature_protocol.py` (add the driver fn that takes the orchestrator). Test: `tests/test_temp_protocol_driver.py`. + +**Interfaces:** Produces `async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, lock_timeout_s=600.0, poll_s=2.0, burst_runner=None) -> dict`. `burst_runner` is an injectable `async (BurstAcquisition)->None` for tests (defaults to `lambda b: b.run(orchestrator)`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_driver.py +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + +class FakeClient: + def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 + async def set_laser_config(self, c): self.laser=c + async def set_led(self, s): self.led=s + async def set_temperature(self, t): self.setpoint=t + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + +class FakeOrch: + def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + @property + def client(self): return self._client + def _emit_event(self, et, data): self.events.append((et, data)) + +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient(); orch = FakeOrch(client) + bursts = [] + async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( + orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, + lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — append to `temperature_protocol.py`: +```python +from gently.app.orchestration.exclusive import BurstAcquisition +from gently.core.event_bus import EventType + +async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, + frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, + lock_timeout_s=600.0, poll_s=2.0, burst_runner=None): + client = orchestrator.client + if burst_runner is None: + async def burst_runner(b): await b.run(orchestrator) + + async def one_burst(phase): + b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF") + b._phase = phase + await burst_runner(b) + + locked = False; error = None; cancelled = False + try: + await client.set_laser_config("ALL OFF") + await client.set_led("Open") + orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, + {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, + "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after}) + for _ in range(bursts_before): + await one_burst("before") + await client.set_temperature(target_setpoint_c) + orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}) + loop = asyncio.get_event_loop(); t0 = loop.time() + while True: + await one_burst("during") + try: + st = str((await client.get_temperature()).get("state", "")) + except Exception: + st = "" + if "LOCKED" in st: + locked = True; break + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True; raise + except Exception as exc: + error = str(exc); logger.exception("temp-change burst protocol failed") + finally: + orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}) + return {"locked": locked, "cancelled": cancelled, "error": error} +``` +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): temp-change burst protocol driver (brightfield before/during/after)` + +--- + +### Task 5: Agent tool + +**Files:** Create `gently/app/tools/temperature_protocol_tools.py` (or add to an existing tools module — follow the `@tool` pattern). Test: `tests/test_temp_protocol_tool.py`. + +**Interfaces:** Produces a `run_temp_change_burst_protocol` agent tool that resolves orchestrator+client from `context`, launches the driver via `asyncio.create_task`, returns a started message; validates embryo/client presence. + +- [ ] **Step 1: failing test** — assert the tool, given a context with a fake orchestrator/client, creates a task and returns a "started" string; given no client, returns an error without creating a task. +> Confirm the real `@tool` decorator + context helpers (`ctx_get(context,"client")`, `require_agent`, `require_timelapse_orchestrator`) from an existing tool (e.g. `gently/app/tools/temperature_tools.py`); mirror them. Write the test to the real registration shape. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — mirror an existing tool: resolve `orchestrator` + `client`, guard None (return error dict/string), `asyncio.create_task(run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, frames=frames, bursts_before=bursts_before, bursts_after=bursts_after))`, return `f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C"`. +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): run_temp_change_burst_protocol agent tool` + +--- + +### Task 6: Observability in strategy_snapshot + +**Files:** Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` ~line 627; mirror the burst-phase handling at ~762/777). Test: `tests/test_temp_protocol_snapshot.py`. + +**Interfaces:** Consumes the timeline subtypes (`temp_protocol_started/completed`, `setpoint_changed`). Produces, in the snapshot: a `temp_protocol` band (open on started, close on completed) and a `setpoint_changes` list. + +- [ ] **Step 1: failing test** — feed `_replay_timeline` (or `build_strategy_snapshot` over a temp `timeline.jsonl`) a sequence: `temp_protocol_started`, `setpoint_changed(to=25)`, `burst_started/completed`, `temp_protocol_completed`; assert the snapshot exposes a temp_protocol span and a setpoint change of 25. +> Confirm the real `_replay_timeline` input/output shape from the existing burst handling; match the snapshot dict structure it already produces. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — in `_replay_timeline`, handle the three subtypes: on `temp_protocol_started` open a band (record start t + params); on `temp_protocol_completed` close it; on `setpoint_changed` append `{t, to}` to a `setpoint_changes` list in the snapshot. Surface them in the returned snapshot dict next to the existing phases. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot` + +--- + +## Self-Review +- §2.1 brightfield burst → Task 2; §2.2 wait-for-lock → Task 3; §2.3 driver → Task 4; §2.4 events → Tasks 1 & 6; §2.5 tool → Task 5. ✓ +- Open confirmations (explicit): the timeline map value-shape (Task 1), the burst `run` acquire_burst call site (Task 2), the `@tool` + context helpers (Task 5), the `_replay_timeline` shape (Task 6). Each names a fallback. +- Type consistency: `laser_config="ALL OFF"` everywhere; event names `TEMPERATURE_SETPOINT_CHANGED`/`TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` across Tasks 1/4/6; driver returns `{locked,cancelled,error}`. diff --git a/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md b/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md new file mode 100644 index 00000000..3989a19a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md @@ -0,0 +1,102 @@ +# Design: Temperature-change burst tactic (sub-project C) + +Status: design decided 2026-06-28 (autonomous, from recon; flag any change before the rig run). +Base branch: off `feature/manual-mode-live-view` (B1) — C composes A's temperature persistence/stamp +and B1's `set_laser_config("ALL OFF")` brightfield primitive. + +## 0. Roadmap context + +Track 2, fast-follow to A + B1. C **automates** the hand-driven experiment B1 enables: bursts +before a temperature setpoint change, bursts during the ramp, bursts after lock — all in +brightfield (LED, no laser). It is the repeatable form of next week's experiment. The Operations-tab +observability rework (D) builds on the timeline events C emits. + +## 1. Overview + +The experiment is a **scripted, wall-clock sequence**, not a reactive rule. Recon confirmed +gently's `MonitoringMode`s are reactive rule-installers with no timed loop, so C is built as **an +agent tool that launches an async timed sequence** (a thin `TimelapseOrchestrator` method so it can +reach `_emit_event` / `queue_burst` / the client), composing primitives that already exist: +- bursts with temperature-stamped persistence + `BURST_START/COMPLETE` timeline events + (`exclusive.py` `BurstAcquisition`), +- `set_temperature` / `get_temperature` with the `'[ SYSTEM LOCKED ]'` state contract, +- `set_laser_config("ALL OFF")` + `set_led("Open")` for brightfield. + +## 2. What must be built (four units) + +### 2.1 Brightfield burst (`laser_config` through the burst op) +`BurstAcquisition.run` calls `client.acquire_burst(...)` but never passes `laser_config`, so it +can't force lasers off. Add an optional `laser_config: str | None = None` to +`BurstAcquisition.__init__` (`exclusive.py`) and thread it into the `acquire_burst(...)` call; add +`laser_config=None` to `TimelapseOrchestrator.queue_burst(...)` and pass it through. A brightfield +burst is then `laser_config="ALL OFF"`. + +### 2.2 Wait-for-lock helper +No wait-for-lock exists. Add `async wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool` +(a small util or orchestrator method): poll `client.get_temperature()` until `"LOCKED" in state`, +return True; return False on timeout. Substring match on `'[ SYSTEM LOCKED ]'`. + +### 2.3 The protocol driver +`TimelapseOrchestrator.run_temp_change_burst_protocol(self, embryo_id, target_setpoint_c, *, +frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, lock_timeout_s=600.0, +poll_s=2.0)` — an `async def`: +1. Brightfield on: `await client.set_laser_config("ALL OFF")`, `await client.set_led("Open")`. +2. Emit `TEMP_PROTOCOL_STARTED` (embryo, target, params). +3. **Before:** run `bursts_before` bursts sequentially — construct + `BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=self._temperature_provider, laser_config="ALL OFF")` and `await burst.run(self)` + (direct sequential run — the protocol owns the sequence; no queue contention for a standalone + experiment). +4. **Change setpoint:** `await client.set_temperature(target_setpoint_c)`; emit + `TEMPERATURE_SETPOINT_CHANGED` (from, to). +5. **During:** loop — run one burst, then `await client.get_temperature()`; break when + `"LOCKED" in state` or `lock_timeout_s` elapsed (event-loop clock). +6. **After:** run `bursts_after` bursts. +7. Emit `TEMP_PROTOCOL_COMPLETED` (counts, final temp). +Each burst is temperature-stamped automatically (A). Bursts emit `BURST_START/COMPLETE` → render +for free. + +### 2.4 Observability events (timeline) +Add `EventType.TEMPERATURE_SETPOINT_CHANGED`, `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`. +Map each in `gently/harness/session/timeline.py`'s EventType→subtype map (so they persist to +`timeline.jsonl`). Handle the subtypes in `strategy_snapshot.py:_replay_timeline` — open a +`"temp_protocol"` band on STARTED, close on COMPLETED, and record setpoint-change markers — so the +Experiment/Operations tab shows the tactic. (`TEMPERATURE_UPDATE` stays high-volume/non-persisted; +only the discrete setpoint *change* is a timeline event.) + +### 2.5 Agent tool +`run_temp_change_burst_protocol(embryo_id, target_setpoint_c, frames=60, bursts_before=1, +bursts_after=1, context=...)` in `gently/app/tools/` — resolves the orchestrator + client, launches +the driver via `asyncio.create_task` (the ramp is minutes; don't block the agent turn), returns a +"protocol started" message. Callable from chat (and later a manual-mode button / Operations tab). + +## 3. Data flow +tool → `asyncio.create_task(orchestrator.run_temp_change_burst_protocol(...))` → brightfield set → +bursts (BurstAcquisition.run → client.acquire_burst(laser_config="ALL OFF") → persisted under +`bursts/{id}/` with temperature stamp) → setpoint change (client.set_temperature + sampler records +`temperature.jsonl`) → poll-until-lock → after-bursts → events to EventBus → `timeline.jsonl` → +`strategy_snapshot` → Experiment tab. + +## 4. Error handling +- No client / no embryo → tool returns an error, no task launched. +- A burst failure mid-protocol → log, emit `TEMP_PROTOCOL_COMPLETED` with an `error` field, restore + state (lasers already off). Never leave lasers on. +- Lock never reached → `during` loop exits on `lock_timeout_s`; protocol proceeds to after-bursts and + notes `locked=false`. +- Cancellation (`asyncio.CancelledError`) → stop queuing, emit completion with `cancelled=true`. + +## 5. Testing +- `BurstAcquisition` passes `laser_config` to a fake `acquire_burst` (unit). +- `wait_for_temperature_lock`: a fake client returning non-locked then locked → True; always + non-locked → False on timeout (use a tiny timeout/poll). +- The driver against fakes (fake client + a `BurstAcquisition.run` patched to a no-op recorder): + asserts the phase order (brightfield set → before bursts → setpoint change → during-until-lock → + after bursts → completed), the emitted events, and `laser_config="ALL OFF"` on every burst. +- timeline map + `strategy_snapshot` replay: the three new subtypes open/close a temp_protocol band + + setpoint marker. +- Rig-deferred: the real thermal ramp timing. + +## 6. Out of scope +- The full Operations-tab visual rework (D) — C only emits the events D will render richly. +- A general user-authored tactic library (G). +- Manual-mode UI button to launch the protocol (nice follow-up; chat/tool is enough for next week). From 7ec42d0a8dc1301c009f95fcb415bd89528ca088 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:28:14 +0530 Subject: [PATCH 096/200] feat(tactic): timeline event types for temp-change burst protocol Add three EventType members (TEMPERATURE_SETPOINT_CHANGED, TEMP_PROTOCOL_STARTED, TEMP_PROTOCOL_COMPLETED) to event_bus.py and map them to timeline subtypes in timeline.py for persistence to timeline.jsonl. Includes unit tests. --- gently/core/event_bus.py | 5 +++++ gently/harness/session/timeline.py | 18 ++++++++++++++++++ tests/test_temp_protocol_events.py | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 tests/test_temp_protocol_events.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 9774bdaa..ee89ab7c 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -127,6 +127,11 @@ class EventType(Enum): BURST_FRAME = auto() # {embryo_id, request_id, frame_idx, total_frames} BURST_COMPLETE = auto() # {embryo_id, request_id, mp4_path, sustained_hz, frames_captured} + # Temperature protocol events (Phase X / 10) — temp-change burst tactic + TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, old_temp, new_temp} + TEMP_PROTOCOL_STARTED = auto() # {embryo_id, protocol_name} + TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, protocol_name, success} + # Reactive control telemetry (Phase 5 / 10) POWER_RAMP_STEP = auto() # {embryo_id, rule, wavelength, old_pct, new_pct, direction} diff --git a/gently/harness/session/timeline.py b/gently/harness/session/timeline.py index b0ae7b92..4f227ec3 100644 --- a/gently/harness/session/timeline.py +++ b/gently/harness/session/timeline.py @@ -228,6 +228,24 @@ def description(self) -> str: "icon": "v", "severity": "success", }, + EventType.TEMPERATURE_SETPOINT_CHANGED: { + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "icon": "T", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_STARTED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "icon": "~", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_COMPLETED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "icon": "+", + "severity": "success", + }, } diff --git a/tests/test_temp_protocol_events.py b/tests/test_temp_protocol_events.py new file mode 100644 index 00000000..7344a985 --- /dev/null +++ b/tests/test_temp_protocol_events.py @@ -0,0 +1,17 @@ +"""Test for temperature protocol event types (Task 1 of temp-change-burst-tactic)""" + +from gently.core.event_bus import EventType + + +def test_new_event_types_exist(): + """Verify the three new EventType members exist""" + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + + +def test_timeline_maps_the_subtypes(): + """Verify timeline.py contains the mapping entries with correct subtypes""" + from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src From 5412c3067daffdcde02977dea7c7c7589ebc8710 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:54:28 +0530 Subject: [PATCH 097/200] feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts) --- gently/app/orchestration/exclusive.py | 3 +++ gently/app/orchestration/timelapse.py | 2 ++ tests/test_burst_laser_config.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 tests/test_burst_laser_config.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index 83b196d7..b741e57d 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -101,12 +101,14 @@ def __init__( num_slices: int = 1, request_id: str | None = None, temperature_provider=None, + laser_config: str | None = None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames self.mode = mode if mode in ("1hz", "asap") else "1hz" self.num_slices = num_slices self._temperature_provider = temperature_provider + self._laser_config = laser_config async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -166,6 +168,7 @@ async def run(self, orchestrator) -> ExclusiveResult: piezo_amplitude=piezo_amplitude, piezo_center=piezo_center, laser_power_488_pct=getattr(embryo, "laser_power_488_pct", None), + laser_config=self._laser_config, ) except Exception as e: logger.error("Burst failed for %s: %s", self.target_embryo_id, e) diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index 5918bd9c..c640f926 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -2061,6 +2061,7 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + laser_config: str | None = None, ) -> str: """Queue a burst acquisition for ``embryo_id``. @@ -2100,6 +2101,7 @@ def queue_burst( mode=mode, num_slices=num_slices, temperature_provider=self._temperature_provider, + laser_config=laser_config, ) self._exclusive_queue.append(op) logger.info( diff --git a/tests/test_burst_laser_config.py b/tests/test_burst_laser_config.py new file mode 100644 index 00000000..9e973715 --- /dev/null +++ b/tests/test_burst_laser_config.py @@ -0,0 +1,23 @@ +"""Task 2: BurstAcquisition laser_config threading tests.""" +import asyncio +import pytest +from gently.app.orchestration.exclusive import BurstAcquisition + + +class FakeClient: + def __init__(self): + self.calls = [] + + async def acquire_burst(self, **kw): + self.calls.append(kw) + return {"success": True, "request_id": "b1", "frames": []} + + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + + +async def test_burst_laser_config_default_none(): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1) + assert b._laser_config is None From 030a4434491dfd72ca7e9f89ecd40d3535edc587 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:04:41 +0530 Subject: [PATCH 098/200] feat(tactic): wait_for_temperature_lock poll helper --- .../app/orchestration/temperature_protocol.py | 17 +++++++++++++++++ tests/test_wait_for_lock.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 gently/app/orchestration/temperature_protocol.py create mode 100644 tests/test_wait_for_lock.py diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py new file mode 100644 index 00000000..d3bc25c2 --- /dev/null +++ b/gently/app/orchestration/temperature_protocol.py @@ -0,0 +1,17 @@ +import asyncio, logging +logger = logging.getLogger(__name__) + +async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: + """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" + loop = asyncio.get_event_loop() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + if "LOCKED" in str(resp.get("state", "")): + return True + if loop.time() - t0 >= timeout_s: + return False + await asyncio.sleep(poll_s) diff --git a/tests/test_wait_for_lock.py b/tests/test_wait_for_lock.py new file mode 100644 index 00000000..6ee8fbe3 --- /dev/null +++ b/tests/test_wait_for_lock.py @@ -0,0 +1,15 @@ +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + +class FakeClient: + def __init__(self, states): self.states = list(states); self.calls = 0 + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1); self.calls += 1 + return {"state": self.states[i]} + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False From 28abaa1fede1cdcac1230aeeff11a113bc2b23cc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:14:40 +0530 Subject: [PATCH 099/200] feat(tactic): temp-change burst protocol driver (brightfield before/during/after) --- .../app/orchestration/temperature_protocol.py | 52 +++++++++++++++++++ tests/test_temp_protocol_driver.py | 39 ++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/test_temp_protocol_driver.py diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index d3bc25c2..60058133 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -1,4 +1,6 @@ import asyncio, logging +from gently.app.orchestration.exclusive import BurstAcquisition +from gently.core.event_bus import EventType logger = logging.getLogger(__name__) async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: @@ -15,3 +17,53 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. if loop.time() - t0 >= timeout_s: return False await asyncio.sleep(poll_s) + + +async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, + frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, + lock_timeout_s=600.0, poll_s=2.0, burst_runner=None): + """Scripted temp-change burst protocol: brightfield before/during/after a setpoint change.""" + client = orchestrator.client + if burst_runner is None: + async def burst_runner(b): await b.run(orchestrator) + + async def one_burst(phase): + b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF") + b._phase = phase + await burst_runner(b) + + locked = False; error = None; cancelled = False + try: + await client.set_laser_config("ALL OFF") + await client.set_led("Open") + orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, + {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, + "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after}) + for _ in range(bursts_before): + await one_burst("before") + await client.set_temperature(target_setpoint_c) + orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}) + loop = asyncio.get_event_loop(); t0 = loop.time() + while True: + await one_burst("during") + try: + st = str((await client.get_temperature()).get("state", "")) + except Exception: + st = "" + if "LOCKED" in st: + locked = True; break + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True; raise + except Exception as exc: + error = str(exc); logger.exception("temp-change burst protocol failed") + finally: + orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}) + return {"locked": locked, "cancelled": cancelled, "error": error} diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py new file mode 100644 index 00000000..b2cdb62d --- /dev/null +++ b/tests/test_temp_protocol_driver.py @@ -0,0 +1,39 @@ +import pytest +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + + +class FakeClient: + def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 + async def set_laser_config(self, c): self.laser=c + async def set_led(self, s): self.led=s + async def set_temperature(self, t): self.setpoint=t + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + + +class FakeOrch: + def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + @property + def client(self): return self._client + def _emit_event(self, et, data): self.events.append((et, data)) + + +@pytest.mark.asyncio +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient(); orch = FakeOrch(client) + bursts = [] + async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( + orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, + lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True From 6af4396f22a310b91f0b0ca6672dcfc387ccda61 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:23:28 +0530 Subject: [PATCH 100/200] feat(tactic): run_temp_change_burst_protocol agent tool Adds temperature_protocol_tools.py with the @tool-decorated run_temp_change_burst_protocol_tool: resolves agent/orchestrator/client from context, guards all three (returns error strings if absent), then launches the driver via asyncio.create_task so the thermal ramp runs in the background and the agent turn returns immediately. Registered in tools/__init__.py. TDD: 5 tests covering happy path (task created + started message), no-client, no-orchestrator, no-agent, and registry discoverability. --- gently/app/tools/__init__.py | 2 + .../app/tools/temperature_protocol_tools.py | 94 ++++++++++ tests/test_temp_protocol_tool.py | 174 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 gently/app/tools/temperature_protocol_tools.py create mode 100644 tests/test_temp_protocol_tool.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index d8ab70db..16d7aebb 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -42,6 +42,7 @@ resolution_tools, session_tools, stage_tools, + temperature_protocol_tools, temperature_tools, timelapse_tools, volume_tools, @@ -88,6 +89,7 @@ def register_all_tools(): "resolution_tools", "session_tools", "stage_tools", + "temperature_protocol_tools", "temperature_tools", "timelapse_tools", "volume_tools", diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py new file mode 100644 index 00000000..dd25d2d5 --- /dev/null +++ b/gently/app/tools/temperature_protocol_tools.py @@ -0,0 +1,94 @@ +""" +Temperature Protocol Tools + +Agent tool for the scripted temp-change burst protocol. The thermal ramp takes +several minutes so the driver runs as a background asyncio task — the agent turn +returns immediately with a "started" confirmation. +""" + +import asyncio + +from gently.harness.tools.helpers import ctx_get, require_agent, require_microscope, require_timelapse_orchestrator +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + + +@tool( + name="run_temp_change_burst_protocol", + description=( + "Start a scripted temperature-change burst protocol in the background. " + "Acquires brightfield burst(s) before the setpoint change, then polls the " + "thermal controller — imaging continuously during the ramp — and acquires " + "burst(s) after lock is reached. " + "The thermal ramp typically takes several minutes; this tool returns " + "immediately and the protocol runs as a background task. " + "Use when you want time-resolved brightfield data spanning a temperature " + "transition (e.g. cold-shock → recovery, 15 C → 25 C developmental-rate shift)." + ), + category=ToolCategory.EXPERIMENT, + requires_microscope=True, + examples=[ + ToolExample( + "Capture a temperature shift from 15 C to 25 C for embryo_1", + {"embryo_id": "embryo_1", "target_setpoint_c": 25.0}, + ), + ToolExample( + "Cold-shock embryo_2 to 15 C with 2 bursts before and after", + {"embryo_id": "embryo_2", "target_setpoint_c": 15.0, "bursts_before": 2, "bursts_after": 2}, + ), + ], +) +async def run_temp_change_burst_protocol_tool( + embryo_id: str, + target_setpoint_c: float, + frames: int = 60, + bursts_before: int = 1, + bursts_after: int = 1, + context: dict | None = None, +) -> str: + """Launch the temp-change burst driver as a background asyncio task. + + Parameters + ---------- + embryo_id : str + ID of the embryo to image during the temperature transition. + target_setpoint_c : float + Target temperature setpoint in degrees Celsius. + frames : int + Number of frames per burst acquisition (default 60). + bursts_before : int + Number of brightfield bursts to capture before the setpoint change (default 1). + bursts_after : int + Number of brightfield bursts to capture after the controller locks (default 1). + """ + agent, err = require_agent(context) + if err: + return err + + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + + client, err = require_microscope(context) + if err: + return err + + from gently.app.orchestration.temperature_protocol import ( + run_temp_change_burst_protocol as _driver, + ) + + asyncio.create_task( + _driver( + orchestrator, + embryo_id, + target_setpoint_c, + frames=frames, + bursts_before=bursts_before, + bursts_after=bursts_after, + ) + ) + + return ( + f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C " + f"({bursts_before} burst(s) before, {bursts_after} burst(s) after lock). " + f"Running in background — use get_temperature to monitor ramp progress." + ) diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py new file mode 100644 index 00000000..a992f5d9 --- /dev/null +++ b/tests/test_temp_protocol_tool.py @@ -0,0 +1,174 @@ +""" +Tests for the run_temp_change_burst_protocol agent tool (Task 5). + +TDD: write failing tests first, then implement the tool. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeClient: + """Minimal fake microscope client.""" + async def set_temperature(self, t): + return {"success": True, "temperature_c": t, "state": "[ HEATING ]"} + + async def get_temperature(self): + return {"success": True, "temperature_c": 20.0, "state": "[ SYSTEM LOCKED ]"} + + +class FakeOrchestrator: + """Minimal fake timelapse orchestrator with a client attribute.""" + def __init__(self, client): + self.client = client + + def _emit_event(self, *args, **kwargs): + pass + + +class FakeAgent: + """Minimal fake agent — carries timelapse_orchestrator.""" + def __init__(self, orchestrator): + self.timelapse_orchestrator = orchestrator + + +def _make_context(*, with_client=True, with_orchestrator=True): + """Build a fake context dict.""" + client = FakeClient() if with_client else None + orchestrator = FakeOrchestrator(client) if with_orchestrator else None + agent = FakeAgent(orchestrator) if with_orchestrator else FakeAgent(None) + return {"agent": agent, "client": client} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tool_creates_task_and_returns_started(): + """Happy path: context has agent+orchestrator+client → task is created, 'started' returned.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + # Cancel the coroutine immediately so there's no dangling task + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + frames=30, + bursts_before=1, + bursts_after=1, + context=context, + ) + + assert len(created_tasks) == 1, "Expected exactly one asyncio task to be created" + assert "started" in result.lower(), f"Expected 'started' in result, got: {result!r}" + assert "emb1" in result, f"Expected embryo_id in result, got: {result!r}" + assert "25.0" in result or "25" in result, f"Expected setpoint in result, got: {result!r}" + + +@pytest.mark.asyncio +async def test_tool_no_client_returns_error_no_task(): + """No client in context → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=False, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when client is absent" + assert "error" in result.lower() or "not connected" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_orchestrator_returns_error_no_task(): + """No timelapse orchestrator → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=False) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when orchestrator is absent" + assert "error" in result.lower() or "not initialized" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_agent_context_returns_error(): + """No agent in context → returns error immediately.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = {"client": FakeClient()} # no "agent" key + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0 + assert "error" in result.lower(), f"Expected error message, got: {result!r}" + + +def test_tool_is_registered(): + """The tool should be discoverable in the global tool registry after package import.""" + import gently.app.tools # noqa: F401 — triggers registration + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_names = [t.name for t in registry.list_all()] + assert "run_temp_change_burst_protocol" in tool_names, ( + f"Tool not found in registry. Registered tools: {tool_names}" + ) From c986c92fd5262c74c9311dfa208cfdb31327a3c5 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:47:15 +0530 Subject: [PATCH 101/200] feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot Initialize temp_protocol (None) and setpoint_changes ([]) on every embryo dict in _build_embryos_static. In _replay_timeline, handle the three new subtypes from Task 1: temp_protocol_started opens a band (start + params), temp_protocol_completed closes it (end), setpoint_changed appends {t, to} to the list. Bursts continue to render as burst phases unchanged. --- gently/ui/web/strategy_snapshot.py | 25 ++++ tests/test_temp_protocol_snapshot.py | 190 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_temp_protocol_snapshot.py diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 91c560e7..4948ae9b 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -552,6 +552,9 @@ def _build_embryos_static( ], "trigger_events": [], "power_history_488": [{"at": 0.0, "pct": float(laser_488)}], + # Filled in by _replay_timeline when temperature-tactic events arrive. + "temp_protocol": None, + "setpoint_changes": [], # Filled in by _project_forward. "projected_cadence_s": initial_cadence, "projected_end_s": None, @@ -782,6 +785,28 @@ def _replay_timeline( # closed at now_offset_s by the tail pass below. _close_open_phase(emb, at_s) + elif subtype == "temp_protocol_started" and embryo_id in by_id: + emb = by_id[embryo_id] + emb["temp_protocol"] = { + "start": at_s, + "end": None, + "target_setpoint_c": data.get("target_setpoint_c"), + "frames": data.get("frames"), + "bursts_before": data.get("bursts_before"), + "bursts_after": data.get("bursts_after"), + } + + elif subtype == "temp_protocol_completed" and embryo_id in by_id: + emb = by_id[embryo_id] + tp = emb.get("temp_protocol") + if tp is not None: + tp["end"] = at_s + + elif subtype == "setpoint_changed" and embryo_id in by_id: + emb = by_id[embryo_id] + to_val = data.get("to") + emb["setpoint_changes"].append({"t": at_s, "to": to_val}) + elif subtype == "stopped": # Session-level stop: close every open phase at this time. for emb in embryo_dicts: diff --git a/tests/test_temp_protocol_snapshot.py b/tests/test_temp_protocol_snapshot.py new file mode 100644 index 00000000..574940fe --- /dev/null +++ b/tests/test_temp_protocol_snapshot.py @@ -0,0 +1,190 @@ +"""Task 6: temp-protocol band + setpoint changes visible in strategy_snapshot. + +Feed _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a sequence: + temp_protocol_started → setpoint_changed(to=25) → + burst_started → burst_completed → temp_protocol_completed + +Assert the returned snapshot exposes: + embryos[0]["temp_protocol"] — a band with start < end, correct params + embryos[0]["setpoint_changes"] — [{t: ..., to: 25}] +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-tp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2025, 1, 1, 10, 0, 0) +TARGET_C = 25.0 + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "temp-protocol test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text( + yaml.dump(timelapse), encoding="utf-8" + ) + + # timeline.jsonl — the sequence under test + events = [ + { + "event_id": "ev-tps", + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": { + "embryo_id": EMBRYO_ID, + "target_setpoint_c": TARGET_C, + "frames": 60, + "bursts_before": 1, + "bursts_after": 1, + }, + }, + { + "event_id": "ev-sc", + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "timestamp": _ts(20), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "to": TARGET_C}, + }, + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(30), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(40), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + { + "event_id": "ev-tpc", + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "timestamp": _ts(50), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "locked": True, "cancelled": False, "error": None}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_temp_protocol_band_present(tmp_path: Path) -> None: + """Snapshot exposes a temp_protocol band with start and end on the embryo.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + + tp = emb.get("temp_protocol") + assert tp is not None, "temp_protocol key missing from embryo" + assert tp["start"] == pytest.approx(10.0), f"expected start=10s, got {tp['start']}" + assert tp["end"] == pytest.approx(50.0), f"expected end=50s, got {tp['end']}" + assert tp["target_setpoint_c"] == pytest.approx(TARGET_C) + + +def test_setpoint_changes_recorded(tmp_path: Path) -> None: + """Snapshot exposes setpoint_changes list with one entry {t, to}.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + sc = emb.get("setpoint_changes") + assert sc is not None, "setpoint_changes key missing from embryo" + assert len(sc) == 1, f"expected 1 setpoint change, got {len(sc)}" + assert sc[0]["t"] == pytest.approx(20.0) + assert sc[0]["to"] == pytest.approx(TARGET_C) + + +def test_burst_phase_still_present(tmp_path: Path) -> None: + """Existing burst phase handling is not disrupted by temp-protocol events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase disappeared after temp-protocol changes" + bp = burst_phases[0] + assert bp["start"] == pytest.approx(30.0) + # end is extended to now_offset_s by the tail-close sweep when no + # cadence_changed follows — just confirm it's set and >= burst start + assert bp["end"] is not None and bp["end"] >= 30.0 + + +def test_temp_protocol_fields_initialized_even_without_events(tmp_path: Path) -> None: + """Embryo dicts always have temp_protocol and setpoint_changes, even with no events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_empty" + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / "session.yaml").write_text("{}", encoding="utf-8") + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + # No timeline.jsonl + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + assert "temp_protocol" in emb, "temp_protocol key absent with no events" + assert "setpoint_changes" in emb, "setpoint_changes key absent with no events" + assert emb["temp_protocol"] is None + assert emb["setpoint_changes"] == [] From dc0387251da3314792a0901741b92f424b9e6c4b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:13:31 +0530 Subject: [PATCH 102/200] fix(tactic): correct event docstrings, guard against concurrent timelapse, harden driver test - event_bus.py: fix TEMPERATURE_SETPOINT_CHANGED/TEMP_PROTOCOL_STARTED/TEMP_PROTOCOL_COMPLETED inline comments to match actual emitted payloads ({embryo_id,to} / {embryo_id,target_setpoint_c, frames,bursts_before,bursts_after} / {embryo_id,locked,cancelled,error}) - temperature_protocol_tools.py: refuse to launch if orchestrator._status == TimelapseStatus.RUNNING (would contend with timelapse RunEngine); returns clear refusal string, no task created - test_temp_protocol_driver.py: assert burst phase sequence (first=="before", last=="after") and event order (STARTED < SETPOINT_CHANGED < COMPLETED) - test_temp_protocol_tool.py: add test_tool_refuses_during_active_timelapse for the guard --- .../app/tools/temperature_protocol_tools.py | 7 +++++ gently/core/event_bus.py | 6 ++-- tests/test_temp_protocol_driver.py | 12 ++++++++ tests/test_temp_protocol_tool.py | 30 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index dd25d2d5..678b08b8 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -75,6 +75,13 @@ async def run_temp_change_burst_protocol_tool( from gently.app.orchestration.temperature_protocol import ( run_temp_change_burst_protocol as _driver, ) + from gently.app.orchestration.timelapse_models import TimelapseStatus + + if getattr(orchestrator, "_status", None) == TimelapseStatus.RUNNING: + return ( + "Refusing to start temp-change protocol: a timelapse is currently running " + "(would contend for the RunEngine). Stop the timelapse first." + ) asyncio.create_task( _driver( diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index ee89ab7c..0b5db1cd 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -128,9 +128,9 @@ class EventType(Enum): BURST_COMPLETE = auto() # {embryo_id, request_id, mp4_path, sustained_hz, frames_captured} # Temperature protocol events (Phase X / 10) — temp-change burst tactic - TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, old_temp, new_temp} - TEMP_PROTOCOL_STARTED = auto() # {embryo_id, protocol_name} - TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, protocol_name, success} + TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, to} + TEMP_PROTOCOL_STARTED = auto() # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after} + TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, locked, cancelled, error} # Reactive control telemetry (Phase 5 / 10) POWER_RAMP_STEP = auto() # {embryo_id, rule, wavelength, old_pct, new_pct, direction} diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py index b2cdb62d..aa112f03 100644 --- a/tests/test_temp_protocol_driver.py +++ b/tests/test_temp_protocol_driver.py @@ -37,3 +37,15 @@ async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser" assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets assert EventType.TEMP_PROTOCOL_COMPLETED in ets assert res["locked"] is True + + # Phase order: first burst must be "before", last must be "after" + phases = [b["phase"] for b in bursts] + assert phases[0] == "before", f"Expected first burst phase 'before', got {phases[0]!r}" + assert phases[-1] == "after", f"Expected last burst phase 'after', got {phases[-1]!r}" + + # Event order: STARTED < SETPOINT_CHANGED < COMPLETED + idx_started = ets.index(EventType.TEMP_PROTOCOL_STARTED) + idx_changed = ets.index(EventType.TEMPERATURE_SETPOINT_CHANGED) + idx_completed = ets.index(EventType.TEMP_PROTOCOL_COMPLETED) + assert idx_started < idx_changed, "TEMP_PROTOCOL_STARTED must precede TEMPERATURE_SETPOINT_CHANGED" + assert idx_changed < idx_completed, "TEMPERATURE_SETPOINT_CHANGED must precede TEMP_PROTOCOL_COMPLETED" diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py index a992f5d9..519c4375 100644 --- a/tests/test_temp_protocol_tool.py +++ b/tests/test_temp_protocol_tool.py @@ -172,3 +172,33 @@ def test_tool_is_registered(): assert "run_temp_change_burst_protocol" in tool_names, ( f"Tool not found in registry. Registered tools: {tool_names}" ) + + +@pytest.mark.asyncio +async def test_tool_refuses_during_active_timelapse(): + """If orchestrator._status == RUNNING, refuse without creating a task.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + from gently.app.orchestration.timelapse_models import TimelapseStatus + + context = _make_context(with_client=True, with_orchestrator=True) + # Simulate an active timelapse + context["agent"].timelapse_orchestrator._status = TimelapseStatus.RUNNING + + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when timelapse is running" + assert "refusing" in result.lower(), f"Expected refusal message, got: {result!r}" + assert "timelapse" in result.lower(), f"Expected 'timelapse' in refusal message, got: {result!r}" From 59c429ab8393cbdeb1777fce2bce92a72d511ad8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:34:41 +0530 Subject: [PATCH 103/200] docs(operations): spec + plan for Operations tab (D, 4 tasks) --- .../plans/2026-06-28-operations-tab.md | 103 ++++++++++++++++++ .../specs/2026-06-28-operations-tab-design.md | 84 ++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-operations-tab.md create mode 100644 docs/superpowers/specs/2026-06-28-operations-tab-design.md diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md new file mode 100644 index 00000000..8ce94846 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -0,0 +1,103 @@ +# Operations Tab (D) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the composed temp-change tactic observable: rename Experiment→Operations, render the protocol band + setpoint markers + phased bursts on the swimlane, and make the tab refresh live. + +**Architecture:** Reuse `GET /api/experiments/{session}/strategy` (C added `temp_protocol` + `setpoint_changes` per-embryo). One backend enabler (emit burst `phase`), the rest is `experiment-overview.js` rendering + live event subscriptions. + +**Tech Stack:** vanilla-JS + hand-rolled SVG (`experiment-overview.js`), the EventBus→websocket relay, pytest for the small backend bit. + +## Global Constraints +- No new endpoints; reuse `/strategy`. C owns the snapshot builder; D consumes its fields. +- Snapshot fields (per-embryo, from C Task 6): `temp_protocol: {start, end, target_setpoint_c, frames, bursts_before, bursts_after} | None`, `setpoint_changes: [{t, to}]`. +- Rename is label-only (`index.html:119` nav text, `:393` `

    `); leave ids + `TABS.EXPERIMENT` unchanged. +- Tactic name is a frontend constant keyed off `temp_protocol` (events carry no name). +- Build against the REAL emitter payloads, not the stale enum docstrings. +- Live: subscribe via `ClientEventBus.on(...)` (mirror `experiment-strip.js:23`), debounced (~500ms) `loadStrategy()`+`render()`. Relay already emits all events by `.name` — no backend change. +- Frontend: no JS unit harness → `node --check` + Chrome-MCP harness + UI audit; live rig verification deferred. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Rename Experiment → Operations (label-only) +**Files:** Modify `gently/ui/web/templates/index.html` (nav button text `:119`, `

    ` `:393`). Test: none (pure copy) — verified by `grep`. +- [ ] **Step 1:** change the nav button text at `:119` from `Experiment` to `Operations`, and the `

    ` at `:393` from `Experiment` to `Operations`. Leave `data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`, `TABS.EXPERIMENT` untouched. +- [ ] **Step 2:** `grep -n ">Operations<" gently/ui/web/templates/index.html` shows both; `grep -n "data-tab=\"experiment\"" ` still present (ids unchanged). +- [ ] **Step 3: commit** — `git add gently/ui/web/templates/index.html && git commit -m "feat(operations): rename Experiment tab to Operations (label only)"` + +--- + +### Task 2: Emit burst `phase` + record it in the snapshot +**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.run` BURST_START emit ~line 127-135 add `phase`); Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` `burst_started` handler ~line 762 record `phase` on the burst phase dict). Test: `tests/test_burst_phase_snapshot.py`. + +**Interfaces:** Produces `BURST_START` data with `"phase": getattr(self, "_phase", None)`; snapshot burst phase dict gains `"phase"`. + +- [ ] **Step 1: failing test** +```python +# tests/test_burst_phase_snapshot.py +import json, os +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +def _write(tmp_path, events): + sd = tmp_path / "sess"; (sd / "embryos" / "e1").mkdir(parents=True) + (sd / "session.yaml").write_text("session_id: s1\nname: t\nstarted_at: '2026-06-28T10:00:00'\n") + (sd / "embryos" / "e1" / "embryo.yaml").write_text("embryo_id: e1\nrole: Test\n") + with open(sd / "timeline.jsonl", "w") as f: + for e in events: f.write(json.dumps(e) + "\n") + return sd + +def test_burst_phase_recorded(tmp_path): + sd = _write(tmp_path, [ + {"event_subtype": "burst_started", "embryo_id": "e1", "timestamp": "2026-06-28T10:00:10", + "data": {"embryo_id": "e1", "frames": 60, "mode": "1hz", "phase": "during"}}, + {"event_subtype": "burst_completed", "embryo_id": "e1", "timestamp": "2026-06-28T10:01:10", + "data": {"embryo_id": "e1"}}, + ]) + snap = build_strategy_snapshot(str(sd), "s1") + emb = next(e for e in snap["embryos"] if e["id"] == "e1") + burst = next(p for p in emb["phases"] if p.get("mode") == "burst") + assert burst.get("phase") == "during" +``` +> Confirm the real `build_strategy_snapshot` session-dir layout + timeline line shape from C Task 6's test (`tests/test_temp_protocol_snapshot.py`) and match it (session.yaml keys, embryo.yaml, timeline event shape). Adapt `_write` to the real shape; keep the `phase` assertion. +- [ ] **Step 2: run, expect FAIL** (phase is None / key absent) +- [ ] **Step 3: implement** — in `exclusive.py` `BurstAcquisition.run`, add `"phase": getattr(self, "_phase", None)` to the `BURST_START` event `data` dict. In `strategy_snapshot.py` `burst_started` handler, add `"phase": data.get("phase")` to the appended burst phase dict (next to `frames`/`hz`). +- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. +- [ ] **Step 5: commit** — `feat(operations): emit burst phase in BURST_START + record in snapshot` + +--- + +### Task 3: Render the tactic band on the swimlane +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`_renderSwimlanes` ~line 332: insert a session-level band; helpers for the protocol pill + setpoint markers, reusing the chip idiom ~728-748). Verify: `node --check` + Chrome-MCP harness. + +- [ ] **Step 1: build the band** — In `_renderSwimlanes`, before drawing embryo rows (which start at `TOP_AXIS_H`), aggregate the protocol from the embryos: find the embryo with a non-null `temp_protocol` (and its `setpoint_changes`). If present, insert a band of fixed height (e.g. 28px) and shift embryo rows down by that height: + - a **tactic pill** rect spanning `xFor(temp_protocol.start)`..`xFor(temp_protocol.end ?? now)`, labeled `Temp-change burst → ${temp_protocol.target_setpoint_c}°C` (a frontend constant prefix); + - for each `setpoint_changes[i]`: a vertical marker line at `xFor(.t)` + a `→ ${.to}°C` chip (reuse the existing chip-drawing helper used for cadence/power chips ~728-748); + - tint the burst blocks (already drawn per-embryo ~558-575) by their `phase` (before/during/after) — e.g. a subtle hue or a 1-char badge — using the `phase` field added in Task 2. + If no embryo has `temp_protocol`, draw no band (layout unchanged) — keep the calm empty state. +- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). +- [ ] **Step 3: Chrome-MCP harness** — build `scratchpad/operationsdemo/` (copy `event-bus.js`, `experiment-overview.js`, `main.css`); an `index.html` that stubs `fetch('/api/experiments/current/strategy')` to return a snapshot with one embryo carrying a `temp_protocol` band (start/end), `setpoint_changes:[{t,to:25}]`, and a few phased burst phases; mount `ExperimentOverview`; screenshot the band; run the UI audit; report deferral of live rig verification. +- [ ] **Step 4: commit** — `feat(operations): tactic band — protocol pill + setpoint markers + phased bursts` + +--- + +### Task 4: Live refresh on events +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`init` — add subscriptions + a debounced refetch). Verify: `node --check` + harness (simulate an event → refetch fires). + +- [ ] **Step 1: implement** — in `ExperimentOverview.init`, after the initial `loadStrategy()`, register (mirroring `experiment-strip.js:23`): +```javascript +const _debouncedReload = (() => { let t; return () => { clearTimeout(t); t = setTimeout(() => { this.loadStrategy().then(() => this.render()); }, 500); }; })(); +['TEMP_PROTOCOL_STARTED','TEMP_PROTOCOL_COMPLETED','TEMPERATURE_SETPOINT_CHANGED','BURST_START','BURST_COMPLETE','EMBRYO_CADENCE_CHANGED','POWER_RAMP_STEP'] + .forEach(ev => ClientEventBus.on(ev, _debouncedReload)); +``` +(Adapt to the real `ExperimentOverview` method names for load/render + `this` binding — confirm from the file; if `loadStrategy` isn't promise-returning, wrap appropriately. Don't double-register if `init` can run twice — guard with a flag.) +- [ ] **Step 2:** `node --check` (exit 0). +- [ ] **Step 3: harness** — in the Task-3 harness, dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch is triggered (e.g. fetch call count increments after the debounce). Screenshot/log. +- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh of the Operations tab` + +--- + +## Self-Review +- Rename → Task 1; burst phase enabler → Task 2; tactic band render → Task 3; live refresh → Task 4. ✓ +- Open confirmations: real `build_strategy_snapshot` layout (Task 2), the `_renderSwimlanes` insertion point + chip helper (Task 3), the real `ExperimentOverview` load/render method names + init-guard (Task 4). +- Type consistency: `temp_protocol {start,end,target_setpoint_c}` + `setpoint_changes [{t,to}]` + burst `phase` across Tasks 2/3/4. diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md new file mode 100644 index 00000000..26a1307a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -0,0 +1,84 @@ +# Design: Operations tab — observable tactics (sub-project D) + +Status: design decided 2026-06-28 (autonomous, from recon). +Base branch: off `feature/temp-change-tactic` (C) — D renders the `temp_protocol`/`setpoint_changes` +snapshot fields C adds, and the protocol events C emits. + +## 0. Roadmap context +Track 2. The user's ask: "when at the beginning of an experiment, tactics have been decided, it +should reflect and be communicated in the experiments tab… tactics should be observable — what and +how tactics are used in operations." Today's Experiment tab is embryo/cadence-centric and has **no +named-tactic concept**, no temperature/setpoint representation, and **does not auto-refresh** +(fetches once per tab click). D makes the composed temp-change tactic observable and live. + +## 1. Overview +Three changes, all small, no new endpoints (reuse `GET /api/experiments/{session}/strategy`): +1. **Rename** the tab "Experiment" → "Operations" (label-only). +2. **Surface the tactic** in the swimlane SVG: a session-level band above the embryo rows showing + the named protocol pill (spanning its start→complete), setpoint-change markers, and burst + grouping/labels (before/during/after). +3. **Live refresh**: subscribe the tab to the relevant EventBus events (already relayed to the + browser) + debounced re-fetch, so it updates during a run instead of only on tab click. + +## 2. Architecture + +### 2.1 Rename (label-only) +`index.html:119` nav button text and `:393` `

    ` text → "Operations". +Leave all ids (`data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`) and +`TABS.EXPERIMENT` (`app.js`) unchanged — routing/JS untouched. + +### 2.2 Burst phase in the event (1-line backend enabler) +The driver sets `b._phase` ("before"/"during"/"after") on each `BurstAcquisition`, but +`BURST_START` does not emit it, so the timeline can't distinguish phases. In +`gently/app/orchestration/exclusive.py` `BurstAcquisition.run`, include `"phase": +getattr(self, "_phase", None)` in the `BURST_START` event data. Then `strategy_snapshot._replay_timeline`'s +`burst_started` handler records `phase` on the burst phase dict (next to `frames`/`hz`). (C Task 6 +already adds the `temp_protocol` band + `setpoint_changes`; this adds per-burst phase labeling.) + +### 2.3 Frontend — the tactic band (`experiment-overview.js`) +Consume the snapshot's new fields (`temp_protocol`: `{start, end, target_setpoint_c, ...}` and +`setpoint_changes`: `[{at, to}]`, from C Task 6) and render, in `_renderSwimlanes` (line 332), a +**session-level band inserted above the per-embryo rows** (the SVG currently starts embryo rows at +`TOP_AXIS_H` — insert one band-height there and shift rows down): +- a **tactic pill** spanning the protocol window: label `"Temp-change burst → {target_setpoint_c}°C"` + (tactic name is a frontend constant keyed off the `temp_protocol` field — events carry no name). +- **setpoint markers** at each `setpoint_changes[].at`: a vertical line + a `"→ N°C"` chip, reusing + the existing cadence/power-chip drawing idiom (lines ~728-748). +- **burst phase tint/labels**: the per-embryo burst blocks (lines ~558-575) already render; tint or + badge those whose `phase` is before/during/after (from 2.2). Empty state unchanged when no tactic. + +### 2.4 Frontend — live refresh (`experiment-overview.js`) +In `ExperimentOverview.init`, add `ClientEventBus.on(...)` subscriptions (mirroring +`experiment-strip.js:23`) for: `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`, +`TEMPERATURE_SETPOINT_CHANGED`, `BURST_START`, `BURST_COMPLETE`, `EMBRYO_CADENCE_CHANGED`, +`POWER_RAMP_STEP` — each calls a **debounced** (~500 ms) `loadStrategy()`+`render()`. The websocket +relay (`server.py:386`) already emits all of these by `.name`, so no backend change. Keep the +existing now-ticker. + +## 3. Data flow +protocol/burst events → EventBus → websocket (by `.name`) → `ClientEventBus` → debounced +`loadStrategy()` → `GET /strategy` → `build_strategy_snapshot` (now with `temp_protocol` + +`setpoint_changes` + per-burst `phase`) → `_renderSwimlanes` draws the tactic band + markers + tinted +bursts. + +## 4. Error handling +- No active session / no tactic → existing calm empty state; the band simply absent when + `temp_protocol` is null. +- Snapshot missing the new fields (older session) → render without the band (defensive optional + chaining). +- Event flood → the debounce coalesces; re-fetch is cheap (single GET). + +## 5. Testing +- Backend: `_replay_timeline` records `phase` on burst phases when `BURST_START` carries it (extend + C Task 6's snapshot test). +- Frontend: `node --check`; a Chrome-MCP harness (like A/B1) feeding a snapshot with a `temp_protocol` + band + `setpoint_changes` + phased bursts → screenshot the tactic band; verify a simulated + `TEMP_PROTOCOL_STARTED` event triggers a debounced re-fetch. UI audit. Live rig verification deferred. + +## 6. Out of scope +- A general user-authored tactic library (G). +- New strategy endpoints or backend snapshot restructuring (reuse `/strategy`; C owns the builder). +- The Rules table (unchanged). +- Wiring the docstring fix for the temp EventTypes (the enum comments wrongly say + `{old_temp,new_temp}`/`{protocol_name,success}`) — fix opportunistically; D builds against the real + emitter payloads (`{to}` / `{locked,cancelled,error}`). From 95f912635ab3907bc0a718ee0dc148b8cc0e3209 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:37:10 +0530 Subject: [PATCH 104/200] feat(operations): rename Experiment tab to Operations (label only) --- gently/ui/web/templates/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1b1dba8e..7edcd230 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -116,7 +116,7 @@

    Take a quick look

    Now
    - +
    @@ -390,7 +390,7 @@

    Embryo Monitoring

    -

    Experiment

    +

    Operations

    From 380336fbc99be1b830b1cf68cca1403696fca084 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:39:56 +0530 Subject: [PATCH 105/200] feat(operations): emit burst phase in BURST_START + record in snapshot --- gently/app/orchestration/exclusive.py | 1 + gently/ui/web/strategy_snapshot.py | 1 + tests/test_burst_phase_snapshot.py | 130 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 tests/test_burst_phase_snapshot.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index b741e57d..03917b0c 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -131,6 +131,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "request_id": self.request_id, "frames": self.frames, "mode": self.mode, + "phase": getattr(self, "_phase", None), }, ) diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 4948ae9b..9eeb57e1 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -774,6 +774,7 @@ def _replay_timeline( "end": None, "frames": int(data.get("frames") or 0), "hz": hz, + "phase": data.get("phase"), } ) diff --git a/tests/test_burst_phase_snapshot.py b/tests/test_burst_phase_snapshot.py new file mode 100644 index 00000000..1c2f28fe --- /dev/null +++ b/tests/test_burst_phase_snapshot.py @@ -0,0 +1,130 @@ +"""Task 2: Burst phase field emitted in BURST_START and recorded in snapshot. + +Feeds _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a burst_started event that carries ``phase="during"`` and +asserts the resulting snapshot burst phase dict includes ``"phase": "during"``. +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-bp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2026, 6, 28, 10, 0, 0) + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-phase test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text( + yaml.dump(timelapse), encoding="utf-8" + ) + + # timeline.jsonl + events = [ + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60, "phase": "during"}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(70), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +def test_burst_phase_recorded(tmp_path: Path) -> None: + """Snapshot burst phase dict carries the phase field from BURST_START data.""" + session_dir = tmp_path / "sessions" / "20260628_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found in snapshot" + bp = burst_phases[0] + assert bp.get("phase") == "during", ( + f"expected phase='during', got {bp.get('phase')!r}; full burst dict: {bp}" + ) + + +def test_burst_phase_none_when_absent(tmp_path: Path) -> None: + """A burst_started event without a phase field records phase=None gracefully.""" + session_dir = tmp_path / "sessions" / "20260628_1000_nophase" + session_dir.mkdir(parents=True, exist_ok=True) + + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-nophase test"}), encoding="utf-8" + ) + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + + events = [ + { + "event_id": "ev-bs2", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 30}, + }, + ] + with open(session_dir / "timeline.jsonl", "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found" + bp = burst_phases[0] + # "phase" key must exist; value is None when not supplied + assert "phase" in bp, f"'phase' key absent from burst dict: {bp}" + assert bp["phase"] is None, f"expected None, got {bp['phase']!r}" From 2a3d8b0e61fe1221fb736ce4645ccdd0bcb3e3a1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:48:21 +0530 Subject: [PATCH 106/200] =?UTF-8?q?feat(operations):=20tactic=20band=20?= =?UTF-8?q?=E2=80=94=20protocol=20pill=20+=20setpoint=20markers=20+=20phas?= =?UTF-8?q?ed=20bursts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a session-level tactic band on the swimlane SVG when any embryo carries a `temp_protocol`. A 28px band is inserted just below the time axis (embryo rows shifted down by the same amount). The band contains a tactic pill spanning `temp_protocol.start → end`, "→ N°C" setpoint-change chips at each `setpoint_changes[].t`, and a gutter label. Burst lane rects and balloon backgrounds are tinted blue/amber/green when the phase field is before/during/after; null phase renders unchanged. node --check: pass. --- gently/ui/web/static/css/experiment.css | 66 ++++++++++++++ .../ui/web/static/js/experiment-overview.js | 88 ++++++++++++++++++- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..6d8ad789 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -626,6 +626,72 @@ color: var(--accent-cyan); } +/* ---- Tactic band — session-level protocol strip above embryo rows ------- */ +/* Background strip */ +.expov-svg-tactic-band-bg { + fill: rgba(245, 158, 11, 0.06); + stroke: rgba(245, 158, 11, 0.14); + stroke-width: 1; +} + +/* Tactic pill: spans the protocol window */ +.expov-svg-tactic-pill { + fill: rgba(245, 158, 11, 0.18); + stroke: rgba(245, 158, 11, 0.60); + stroke-width: 1; +} +.expov-svg-tactic-pill-label { + fill: #fbbf24; + font-size: 9.5px; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; +} + +/* Gutter label ("tactic") */ +.expov-svg-tactic-band-label { + fill: var(--text-muted); + font-size: 9px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.7; +} + +/* Setpoint marker — vertical line + "→ N°C" chip */ +.expov-svg-tactic-setpoint-line { + stroke: rgba(245, 158, 11, 0.55); + stroke-width: 1; + stroke-dasharray: 3 2; +} +.expov-svg-tactic-setpoint-chip-bg { + fill: rgba(30, 20, 0, 0.88); + stroke: rgba(245, 158, 11, 0.55); + stroke-width: 1; +} +.expov-svg-tactic-setpoint-chip { + fill: #fbbf24; + font-size: 9px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; +} + +/* Burst phase tints — override fill on the burst lane rect + balloon bg. + before = cool blue (pre-stimulus), during = warm amber (active), + after = soft green (recovery). null phase renders as the default red. */ +.expov-burst-phase-before { + fill: #3b82f6; + opacity: 0.80; +} +.expov-burst-phase-during { + fill: #f59e0b; + opacity: 0.90; +} +.expov-burst-phase-after { + fill: #22c55e; + opacity: 0.80; +} + .expov-trigger-diamond-inline { display: inline-block; width: 8px; diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 9e7fc221..eddd97d8 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -367,8 +367,22 @@ const ExperimentOverview = { const TOP_AXIS_H = 36; // top axis area (time labels + wall-clock) const BOTTOM_PAD = 8; + const TACTIC_BAND_H = 28; // session-level tactic band height + + // Aggregate tactic protocol from embryos (first embryo with one wins) + let tacticProto = null; + let tacticSetpointChanges = []; + for (const emb of s.embryos) { + if (emb.temp_protocol) { + tacticProto = emb.temp_protocol; + tacticSetpointChanges = emb.setpoint_changes || []; + break; + } + } + const bandShift = tacticProto ? TACTIC_BAND_H : 0; + const rows = s.embryos.length; - const H = TOP_AXIS_H + rows * ROW_H + BOTTOM_PAD; + const H = TOP_AXIS_H + bandShift + rows * ROW_H + BOTTOM_PAD; const svg = svgEl('svg', { class: 'expov-swimlanes-svg', @@ -426,6 +440,67 @@ const ExperimentOverview = { nowMarker.appendChild(chipText); svg.appendChild(nowMarker); + // ----- tactic band: session-level protocol pill + setpoint markers + if (tacticProto) { + const bandY = TOP_AXIS_H + 2; + const bandContentH = TACTIC_BAND_H - 4; + const bandMidY = bandY + bandContentH / 2; + const bandG = svgEl('g', { class: 'expov-tactic-band-g' }); + + // Background strip spanning the full lane area + bandG.appendChild(svgEl('rect', { + x: LEFT, y: bandY, width: LANE_W, height: bandContentH, rx: 3, + class: 'expov-svg-tactic-band-bg' + })); + + // Tactic pill: spans protocol start → end (or now if still running) + const pillX0 = xForT(tacticProto.start); + const pillX1 = xForT(tacticProto.end != null ? tacticProto.end : s.now_offset_s); + const pillW = Math.max(4, pillX1 - pillX0); + const pillLabel = `Temp-change burst → ${tacticProto.target_setpoint_c}°C`; + bandG.appendChild(svgEl('rect', { + x: pillX0, y: bandY + 2, width: pillW, height: bandContentH - 4, rx: 4, + class: 'expov-svg-tactic-pill' + })); + if (pillW > 24) { + bandG.appendChild(svgEl('text', { + x: pillX0 + 7, y: bandMidY + 3.5, + class: 'expov-svg-tactic-pill-label' + }, pillLabel)); + } + + // Setpoint markers: vertical line + "→ N°C" chip (reuses chip idiom) + tacticSetpointChanges.forEach(sc => { + const scX = xForT(sc.t); + const markerLabel = `→ ${sc.to}°C`; + const chipW = markerLabel.length * 5.6 + 10; + const chipY = bandY + 2; + bandG.appendChild(svgEl('line', { + x1: scX, x2: scX, y1: bandY, y2: bandY + bandContentH, + class: 'expov-svg-tactic-setpoint-line' + })); + bandG.appendChild(svgEl('rect', { + x: scX - chipW / 2, y: chipY, + width: chipW, height: 12, rx: 3, + class: 'expov-svg-tactic-setpoint-chip-bg' + })); + bandG.appendChild(svgEl('text', { + x: scX, y: chipY + 9, + 'text-anchor': 'middle', + class: 'expov-svg-tactic-setpoint-chip' + }, markerLabel)); + }); + + // Gutter label + bandG.appendChild(svgEl('text', { + x: LEFT - 8, y: bandMidY + 3.5, + 'text-anchor': 'end', + class: 'expov-svg-tactic-band-label' + }, 'tactic')); + + svg.appendChild(bandG); + } + // Stash the bits the ticker needs to update without re-rendering. this._nowTickerCtx = { marker: nowMarker, @@ -444,7 +519,7 @@ const ExperimentOverview = { // ----- one group per embryo s.embryos.forEach((emb, i) => { - const rowTop = TOP_AXIS_H + i * ROW_H; + const rowTop = TOP_AXIS_H + bandShift + i * ROW_H; const rowG = svgEl('g'); rowG.appendChild(this._renderLaneRow(s, emb, { LEFT, LANE_W, RIGHT, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, @@ -537,7 +612,9 @@ const ExperimentOverview = { const x1Raw = xForT(ph.end); const x1 = Math.max(x1Raw, x0 + 4); const width = x1 - x0; - const cls = `expov-svg-phase-${ph.mode}`; + const cls = (ph.mode === 'burst' && ph.phase) + ? `expov-svg-phase-${ph.mode} expov-burst-phase-${ph.phase}` + : `expov-svg-phase-${ph.mode}`; g.appendChild(svgEl('rect', { x: x0, y: laneY, width, height: LANE_H, rx: 2, class: cls @@ -559,9 +636,12 @@ const ExperimentOverview = { const bx = (xForT(ph.start) + xForT(ph.end)) / 2; const balloonY = laneY - 12; const halfW = 30; + const balloonCls = ph.phase + ? `expov-svg-burst-balloon-bg expov-burst-phase-${ph.phase}` + : 'expov-svg-burst-balloon-bg'; g.appendChild(svgEl('rect', { x: bx - halfW, y: balloonY - 10, width: halfW * 2, height: 12, rx: 3, - class: 'expov-svg-burst-balloon-bg' + class: balloonCls })); g.appendChild(svgEl('text', { x: bx, y: balloonY - 1, From 6003b8ee510f28b044460ca0102f12eef63f4921 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:16:04 +0530 Subject: [PATCH 107/200] docs(operations): redesign D as operation-spine (data-driven + scenario mode) after design audit --- .../plans/2026-06-28-operations-tab.md | 123 +++++------- .../specs/2026-06-28-operations-tab-design.md | 177 ++++++++++-------- 2 files changed, 144 insertions(+), 156 deletions(-) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index 8ce94846..ab1768b2 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -1,103 +1,70 @@ -# Operations Tab (D) Implementation Plan +# Operations — operation spine (D, redesigned) Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. -**Goal:** Make the composed temp-change tactic observable: rename Experiment→Operations, render the protocol band + setpoint markers + phased bursts on the swimlane, and make the tab refresh live. +**Goal:** Replace the legacy swimlane with a data-driven **operation-spine** Operations view that shows tactics planned/in-use/used, renders any tactic case from a generic model, and is testable offline via scenario fixtures. -**Architecture:** Reuse `GET /api/experiments/{session}/strategy` (C added `temp_protocol` + `setpoint_changes` per-embryo). One backend enabler (emit burst `phase`), the rest is `experiment-overview.js` rendering + live event subscriptions. +**Architecture:** A generic *operation* model (tactics with state + optional live readouts/phases); a rewritten `experiment-overview.js` overview = the spine renderer (audit fixes baked in); a repo scenario library (6 fixtures) + `?scenario=` dev mode; a backend `build_operation_view(snapshot)` transform served on `/strategy`; live event-driven refresh. -**Tech Stack:** vanilla-JS + hand-rolled SVG (`experiment-overview.js`), the EventBus→websocket relay, pytest for the small backend bit. +**Reference (the validated prototype — read it for the exact renderer, CSS, model, and fixtures):** +`/tmp/claude-1000/-home-dna-lab-projects-gently/3192c9a4-bc99-4c0a-9cbd-e094de730bcf/scratchpad/opsdesign/harness.html` ## Global Constraints -- No new endpoints; reuse `/strategy`. C owns the snapshot builder; D consumes its fields. -- Snapshot fields (per-embryo, from C Task 6): `temp_protocol: {start, end, target_setpoint_c, frames, bursts_before, bursts_after} | None`, `setpoint_changes: [{t, to}]`. -- Rename is label-only (`index.html:119` nav text, `:393` `

    `); leave ids + `TABS.EXPERIMENT` unchanged. -- Tactic name is a frontend constant keyed off `temp_protocol` (events carry no name). -- Build against the REAL emitter payloads, not the stale enum docstrings. -- Live: subscribe via `ClientEventBus.on(...)` (mirror `experiment-strip.js:23`), debounced (~500ms) `loadStrategy()`+`render()`. Relay already emits all events by `.name` — no backend change. -- Frontend: no JS unit harness → `node --check` + Chrome-MCP harness + UI audit; live rig verification deferred. +- Keep the Experiment→Operations rename (done) + the burst-`phase` event (done). DISCARD the swimlane band. +- Operation model: `{title, session, tactics:[{seq, state:'used'|'active'|'queued', name, kind, target?, summary?, desc?, trigger?, live?:{readouts:[{label,value,sub?,bar?}], phases?:[{name,state:'done'|'active'|'todo',count,pips:[...]}]}}]}`. +- Audit fixes (baked into the renderer): (1) queued spine is colored blue + first queued marked "next" (decided-plan must read as a cocked instrument, not empty); (2) active phase dominates + "awaiting lock"-style status is the phase headline; (3) FLATTEN — no card-in-card, values on the panel face + hairline dividers; (4) active row's whole left column amber + colored left edge per state (green/amber/blue); (5) copy "queued" (not "going to be used"), replace "first/second/last" with `trigger`. Keep mono instrument values + domain copy. +- Renderer must handle ALL six fixtures (phased protocol, readout monitor, one-shot, all-queued, idle) without error. +- No new endpoint: extend `/api/experiments/{session}/strategy` payload with an `operation` object. +- Frontend has no JS unit harness → `node --check` + Chrome-MCP audit across fixtures. - Git hygiene: stage only your files by explicit path; never `git add -A`. --- -### Task 1: Rename Experiment → Operations (label-only) -**Files:** Modify `gently/ui/web/templates/index.html` (nav button text `:119`, `

    ` `:393`). Test: none (pure copy) — verified by `grep`. -- [ ] **Step 1:** change the nav button text at `:119` from `Experiment` to `Operations`, and the `

    ` at `:393` from `Experiment` to `Operations`. Leave `data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`, `TABS.EXPERIMENT` untouched. -- [ ] **Step 2:** `grep -n ">Operations<" gently/ui/web/templates/index.html` shows both; `grep -n "data-tab=\"experiment\"" ` still present (ids unchanged). -- [ ] **Step 3: commit** — `git add gently/ui/web/templates/index.html && git commit -m "feat(operations): rename Experiment tab to Operations (label only)"` +### Task 1: Discard the swimlane band +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (+ its css) — revert the tactic-band rendering added in commit 2a3d8b0, returning the Overview to its pre-band baseline (the spine replaces it in Task 2). Keep everything else (Rules view, modes, etc.). +- [ ] **Step 1:** `git revert --no-commit 2a3d8b0` (or manually remove the band code + css if revert conflicts), keeping the rename (Task-1) and burst-phase (Task-2) commits intact. Confirm `git diff` shows only the band removal. +- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). +- [ ] **Step 3: commit** — `git add gently/ui/web/static/js/experiment-overview.js && git commit -m "feat(operations): discard swimlane tactic-band (replaced by operation spine)"` --- -### Task 2: Emit burst `phase` + record it in the snapshot -**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.run` BURST_START emit ~line 127-135 add `phase`); Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` `burst_started` handler ~line 762 record `phase` on the burst phase dict). Test: `tests/test_burst_phase_snapshot.py`. - -**Interfaces:** Produces `BURST_START` data with `"phase": getattr(self, "_phase", None)`; snapshot burst phase dict gains `"phase"`. - -- [ ] **Step 1: failing test** -```python -# tests/test_burst_phase_snapshot.py -import json, os -from gently.ui.web.strategy_snapshot import build_strategy_snapshot - -def _write(tmp_path, events): - sd = tmp_path / "sess"; (sd / "embryos" / "e1").mkdir(parents=True) - (sd / "session.yaml").write_text("session_id: s1\nname: t\nstarted_at: '2026-06-28T10:00:00'\n") - (sd / "embryos" / "e1" / "embryo.yaml").write_text("embryo_id: e1\nrole: Test\n") - with open(sd / "timeline.jsonl", "w") as f: - for e in events: f.write(json.dumps(e) + "\n") - return sd - -def test_burst_phase_recorded(tmp_path): - sd = _write(tmp_path, [ - {"event_subtype": "burst_started", "embryo_id": "e1", "timestamp": "2026-06-28T10:00:10", - "data": {"embryo_id": "e1", "frames": 60, "mode": "1hz", "phase": "during"}}, - {"event_subtype": "burst_completed", "embryo_id": "e1", "timestamp": "2026-06-28T10:01:10", - "data": {"embryo_id": "e1"}}, - ]) - snap = build_strategy_snapshot(str(sd), "s1") - emb = next(e for e in snap["embryos"] if e["id"] == "e1") - burst = next(p for p in emb["phases"] if p.get("mode") == "burst") - assert burst.get("phase") == "during" -``` -> Confirm the real `build_strategy_snapshot` session-dir layout + timeline line shape from C Task 6's test (`tests/test_temp_protocol_snapshot.py`) and match it (session.yaml keys, embryo.yaml, timeline event shape). Adapt `_write` to the real shape; keep the `phase` assertion. -- [ ] **Step 2: run, expect FAIL** (phase is None / key absent) -- [ ] **Step 3: implement** — in `exclusive.py` `BurstAcquisition.run`, add `"phase": getattr(self, "_phase", None)` to the `BURST_START` event `data` dict. In `strategy_snapshot.py` `burst_started` handler, add `"phase": data.get("phase")` to the appended burst phase dict (next to `frames`/`hz`). -- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. -- [ ] **Step 5: commit** — `feat(operations): emit burst phase in BURST_START + record in snapshot` +### Task 2: Scenario library + the operation-spine renderer (the core) +**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (the 6 fixtures, promoted from the harness); Modify `gently/ui/web/static/js/experiment-overview.js` (replace the Overview render path with the data-driven spine renderer from the harness, audit fixes applied) + the experiment css (spine/node/phase styles from the harness). Verify: `node --check` + Chrome-MCP audit. + +**Interfaces:** Produces `OperationView.render(rootEl, operationModel)` (or fold into `ExperimentOverview`); consumes the operation model (§ Global Constraints) from the live snapshot (Task 3) or a scenario fixture; `OPERATIONS_SCENARIOS` (the fixture map) + a `?scenario=` loader (dev mode). + +- [ ] **Step 1: port the fixtures** — create `operations-scenarios.js` exposing `window.OPERATIONS_SCENARIOS = {temp_strain, expression_onset, pre_hatching, transmission_survey, decided_plan, idle}` — copy the fixture objects from the harness `SCENARIOS` (the `.op` values), as the repo test corpus. Apply the copy fixes: `state:'queued'` (not 'planned' label), add `trigger` fields ("when temp locks", "after #1", etc.) replacing "first/second/last". +- [ ] **Step 2: the renderer** — port the harness renderer (`renderOperation`/`renderTactic`/`renderReadout`/`renderPhase`) into `experiment-overview.js`, replacing the swimlane Overview path, WITH the audit fixes: state-colored spine connector (green/amber/blue-dashed), whole-left-column amber for active, colored left edge per state, flattened active card (remove inner card backgrounds; hairline divider between readouts and phases), active phase dominant + status headline, first-queued "next" marker, `STATE_LABEL.queued='queued'`. Keep mono labels. +- [ ] **Step 3: scenario dev mode** — on init, if `new URLSearchParams(location.search).get('scenario')` matches a fixture, render that fixture (no fetch); else fetch the live operation (Task 3). Guard so scenario mode is opt-in via the param. +- [ ] **Step 4: `node --check`** both JS files (exit 0). +- [ ] **Step 5: Chrome-MCP audit** — serve the static files, load `?scenario=temp_strain`, `expression_onset`, `decided_plan`, `idle`; screenshot each; verify the audit fixes (queued reads cocked, active dominates, flattened, amber left column, "queued" copy) and that all six render without error; fix flaws. +- [ ] **Step 6: commit** — `feat(operations): operation-spine renderer + scenario library (data-driven, 6 cases)` --- -### Task 3: Render the tactic band on the swimlane -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`_renderSwimlanes` ~line 332: insert a session-level band; helpers for the protocol pill + setpoint markers, reusing the chip idiom ~728-748). Verify: `node --check` + Chrome-MCP harness. +### Task 3: Backend transform `build_operation_view` +**Files:** Modify `gently/ui/web/strategy_snapshot.py` (add `build_operation_view(snapshot)` mapping the snapshot → operation model; have `build_strategy_snapshot` include `operation` in its return, OR add it in the route). Test: `tests/test_operation_view.py`. -- [ ] **Step 1: build the band** — In `_renderSwimlanes`, before drawing embryo rows (which start at `TOP_AXIS_H`), aggregate the protocol from the embryos: find the embryo with a non-null `temp_protocol` (and its `setpoint_changes`). If present, insert a band of fixed height (e.g. 28px) and shift embryo rows down by that height: - - a **tactic pill** rect spanning `xFor(temp_protocol.start)`..`xFor(temp_protocol.end ?? now)`, labeled `Temp-change burst → ${temp_protocol.target_setpoint_c}°C` (a frontend constant prefix); - - for each `setpoint_changes[i]`: a vertical marker line at `xFor(.t)` + a `→ ${.to}°C` chip (reuse the existing chip-drawing helper used for cadence/power chips ~728-748); - - tint the burst blocks (already drawn per-embryo ~558-575) by their `phase` (before/during/after) — e.g. a subtle hue or a 1-char badge — using the `phase` field added in Task 2. - If no embryo has `temp_protocol`, draw no band (layout unchanged) — keep the calm empty state. -- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). -- [ ] **Step 3: Chrome-MCP harness** — build `scratchpad/operationsdemo/` (copy `event-bus.js`, `experiment-overview.js`, `main.css`); an `index.html` that stubs `fetch('/api/experiments/current/strategy')` to return a snapshot with one embryo carrying a `temp_protocol` band (start/end), `setpoint_changes:[{t,to:25}]`, and a few phased burst phases; mount `ExperimentOverview`; screenshot the band; run the UI audit; report deferral of live rig verification. -- [ ] **Step 4: commit** — `feat(operations): tactic band — protocol pill + setpoint markers + phased bursts` +**Interfaces:** Produces `build_operation_view(snapshot: dict) -> dict` (the operation model); `/api/experiments/{session}/strategy` response gains `operation`. ---- +- [ ] **Step 1: failing test** — feed a snapshot dict (one embryo with a `temp_protocol` band {start,end,target_setpoint_c}, `setpoint_changes`, phased bursts, and an active monitoring mode) to `build_operation_view`; assert the returned `operation.tactics` includes: a `used` monitor tactic, an `active` temp-change tactic whose `live.phases` reflect before/during/after, and the `state`s are correct. (Confirm the real snapshot shape from `tests/test_temp_protocol_snapshot.py`.) +- [ ] **Step 2: run, FAIL.** +- [ ] **Step 3: implement** `build_operation_view`: derive tactics from the snapshot — completed monitoring modes/bursts → `used`; the active `temp_protocol`/monitoring mode → `active` (with readouts from temp + current burst, phases from the protocol's before/during/after burst counts); planned/queued items → `queued`. Map fields into the §model. Include `operation` in the snapshot/route payload. +- [ ] **Step 4: run, PASS;** `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(operations): build_operation_view transform on the strategy snapshot` -### Task 4: Live refresh on events -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`init` — add subscriptions + a debounced refetch). Verify: `node --check` + harness (simulate an event → refetch fires). +--- -- [ ] **Step 1: implement** — in `ExperimentOverview.init`, after the initial `loadStrategy()`, register (mirroring `experiment-strip.js:23`): -```javascript -const _debouncedReload = (() => { let t; return () => { clearTimeout(t); t = setTimeout(() => { this.loadStrategy().then(() => this.render()); }, 500); }; })(); -['TEMP_PROTOCOL_STARTED','TEMP_PROTOCOL_COMPLETED','TEMPERATURE_SETPOINT_CHANGED','BURST_START','BURST_COMPLETE','EMBRYO_CADENCE_CHANGED','POWER_RAMP_STEP'] - .forEach(ev => ClientEventBus.on(ev, _debouncedReload)); -``` -(Adapt to the real `ExperimentOverview` method names for load/render + `this` binding — confirm from the file; if `loadStrategy` isn't promise-returning, wrap appropriately. Don't double-register if `init` can run twice — guard with a flag.) +### Task 4: Live refresh +**Files:** Modify `experiment-overview.js` (`init` — subscribe + debounced refetch, mirroring `experiment-strip.js`). Verify: `node --check` + harness (simulate an event → refetch). +- [ ] **Step 1:** in `init`, after the initial live fetch, register `ClientEventBus.on(...)` for `TEMP_PROTOCOL_STARTED`,`TEMP_PROTOCOL_COMPLETED`,`TEMPERATURE_SETPOINT_CHANGED`,`BURST_START`,`BURST_COMPLETE`,`EMBRYO_CADENCE_CHANGED`,`POWER_RAMP_STEP`, each → a debounced (~500ms) refetch+render. Guard against double-registration; skip in scenario mode. - [ ] **Step 2:** `node --check` (exit 0). -- [ ] **Step 3: harness** — in the Task-3 harness, dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch is triggered (e.g. fetch call count increments after the debounce). Screenshot/log. -- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh of the Operations tab` +- [ ] **Step 3: harness** — dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch fires after the debounce. +- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh` --- ## Self-Review -- Rename → Task 1; burst phase enabler → Task 2; tactic band render → Task 3; live refresh → Task 4. ✓ -- Open confirmations: real `build_strategy_snapshot` layout (Task 2), the `_renderSwimlanes` insertion point + chip helper (Task 3), the real `ExperimentOverview` load/render method names + init-guard (Task 4). -- Type consistency: `temp_protocol {start,end,target_setpoint_c}` + `setpoint_changes [{t,to}]` + burst `phase` across Tasks 2/3/4. +- Discard swimlane → Task 1; renderer+scenarios → Task 2 (audit fixes baked in); backend transform → Task 3; live refresh → Task 4. ✓ +- Open confirmations: the real snapshot shape (Task 3, from C's snapshot test); the `experiment-overview.js` Overview render seam to replace (Task 2, read the harness for the target renderer); the css file for experiment styles (Task 1/2). +- Type consistency: the operation model fields are identical across the fixtures (Task 2), the transform (Task 3), and the renderer (Task 2). diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md index 26a1307a..6d57416e 100644 --- a/docs/superpowers/specs/2026-06-28-operations-tab-design.md +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -1,84 +1,105 @@ -# Design: Operations tab — observable tactics (sub-project D) - -Status: design decided 2026-06-28 (autonomous, from recon). -Base branch: off `feature/temp-change-tactic` (C) — D renders the `temp_protocol`/`setpoint_changes` -snapshot fields C adds, and the protocol events C emits. - -## 0. Roadmap context -Track 2. The user's ask: "when at the beginning of an experiment, tactics have been decided, it -should reflect and be communicated in the experiments tab… tactics should be observable — what and -how tactics are used in operations." Today's Experiment tab is embryo/cadence-centric and has **no -named-tactic concept**, no temperature/setpoint representation, and **does not auto-refresh** -(fetches once per tab click). D makes the composed temp-change tactic observable and live. - -## 1. Overview -Three changes, all small, no new endpoints (reuse `GET /api/experiments/{session}/strategy`): -1. **Rename** the tab "Experiment" → "Operations" (label-only). -2. **Surface the tactic** in the swimlane SVG: a session-level band above the embryo rows showing - the named protocol pill (spanning its start→complete), setpoint-change markers, and burst - grouping/labels (before/during/after). -3. **Live refresh**: subscribe the tab to the relevant EventBus events (already relayed to the - browser) + debounced re-fetch, so it updates during a run instead of only on tab click. - -## 2. Architecture - -### 2.1 Rename (label-only) -`index.html:119` nav button text and `:393` `

    ` text → "Operations". -Leave all ids (`data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`) and -`TABS.EXPERIMENT` (`app.js`) unchanged — routing/JS untouched. - -### 2.2 Burst phase in the event (1-line backend enabler) -The driver sets `b._phase` ("before"/"during"/"after") on each `BurstAcquisition`, but -`BURST_START` does not emit it, so the timeline can't distinguish phases. In -`gently/app/orchestration/exclusive.py` `BurstAcquisition.run`, include `"phase": -getattr(self, "_phase", None)` in the `BURST_START` event data. Then `strategy_snapshot._replay_timeline`'s -`burst_started` handler records `phase` on the burst phase dict (next to `frames`/`hz`). (C Task 6 -already adds the `temp_protocol` band + `setpoint_changes`; this adds per-burst phase labeling.) - -### 2.3 Frontend — the tactic band (`experiment-overview.js`) -Consume the snapshot's new fields (`temp_protocol`: `{start, end, target_setpoint_c, ...}` and -`setpoint_changes`: `[{at, to}]`, from C Task 6) and render, in `_renderSwimlanes` (line 332), a -**session-level band inserted above the per-embryo rows** (the SVG currently starts embryo rows at -`TOP_AXIS_H` — insert one band-height there and shift rows down): -- a **tactic pill** spanning the protocol window: label `"Temp-change burst → {target_setpoint_c}°C"` - (tactic name is a frontend constant keyed off the `temp_protocol` field — events carry no name). -- **setpoint markers** at each `setpoint_changes[].at`: a vertical line + a `"→ N°C"` chip, reusing - the existing cadence/power-chip drawing idiom (lines ~728-748). -- **burst phase tint/labels**: the per-embryo burst blocks (lines ~558-575) already render; tint or - badge those whose `phase` is before/during/after (from 2.2). Empty state unchanged when no tactic. - -### 2.4 Frontend — live refresh (`experiment-overview.js`) -In `ExperimentOverview.init`, add `ClientEventBus.on(...)` subscriptions (mirroring -`experiment-strip.js:23`) for: `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`, -`TEMPERATURE_SETPOINT_CHANGED`, `BURST_START`, `BURST_COMPLETE`, `EMBRYO_CADENCE_CHANGED`, -`POWER_RAMP_STEP` — each calls a **debounced** (~500 ms) `loadStrategy()`+`render()`. The websocket -relay (`server.py:386`) already emits all of these by `.name`, so no backend change. Keep the -existing now-ticker. +# Design: Operations tab — the operation spine (sub-project D, redesigned) + +Status: redesigned 2026-06-28 after a from-scratch design analysis + audit (user directive: +"start from no assumptions of legacy design… rethink completely new"). Supersedes the earlier +swimlane-band approach (discarded). +Base branch: `feature/operations-tab` (off C). Keep: the Experiment→Operations rename and the +burst-`phase` event. **Discard: the swimlane tactic-band rendering.** + +## 0. The directive + +Operations must present **tactics in three states — planned ("queued"), in use, and used** — so a +scientist reads at a glance what the agent decided, what it's doing now, and what's next. It must be +**testable against realistic experiments** and render **different tactic cases** (monitor, burst, +temp-change protocol, transmission, …). It is **mission-control for an autonomous instrument**, not +a timeline and not a SaaS dashboard. + +## 1. The model — a generic tactic plan + +Operations renders one data structure, the **operation view**: + +``` +operation = { title, session, tactics: [ tactic ] } +tactic = { + seq, state: 'used'|'active'|'queued', name, kind, + target?, # e.g. "→ 32.0 °C" + summary?, # collapsed line (used/queued): "22 min · ended on signal" + desc?, # one-line sub-description + trigger?, # queued: the condition that starts it, e.g. "when temp locks" + live?: { # active (or any with live data) + readouts: [{ label, value, sub?, bar? }], # temp gauge, current burst, signal, cadence… + phases?: [{ name, state:'done'|'active'|'todo', count, pips:[...] }] # before/during/after (protocols) + } +} +``` + +This single model renders **any** tactic kind: a phased protocol (temp-change → phases + temp +gauge), a reactive monitor (expression-onset → readouts, no phases), a one-shot (transmission burst +→ readouts), or a purely queued plan (all tactics `state:'queued'`). + +## 2. Architecture (four units) + +### 2.1 Backend transform — `build_operation_view(snapshot)` +A new function (in `gently/ui/web/strategy_snapshot.py` or a sibling) that maps the existing +strategy snapshot into the operation model: the per-embryo `temp_protocol` band + `setpoint_changes` ++ phased bursts (from C) become a temp-change **tactic** with before/during/after **phases**; active +`monitoring_modes` become monitor **tactics** with **readouts** (cadence, power, signal); completed +bursts/phases become **used** tactics; not-yet-started planned items become **queued** tactics. +Served via the existing `/api/experiments/{session}/strategy` route (extend its payload with +`operation`) or a new `GET /api/operations/{session}` — reuse `/strategy` to avoid a new route. + +### 2.2 The renderer — rewrite `experiment-overview.js` +Replace the swimlane Overview with the **operation spine** (data-driven from §1): a vertical thread +of tactic nodes; **used** = solid green dot + collapsed one-line summary; **in use** = amber node, +the *whole left column amber* (dot + seq + "IN USE"), card bloomed open with live internals; **queued** += blue dashed dot + dimmed card + trigger condition. The spine connector is **state-colored** +(green used → amber active → blue-dashed queued); the first queued node is marked **next**. (The +Rules view can remain as-is or be folded in later — out of scope here.) + +**Audit fixes baked in** (from the design audit): +1. Queued/decided-plan state reads as a *cocked* instrument: colored blue queued thread + "next" marker. +2. The **active phase dominates** the stepper; **"awaiting lock" is the headline** of the active phase. +3. **Flatten** the active card — no card-in-card; values sit on the panel face, separated by a hairline. +4. Active row's **entire left column amber**; colored left edge per state (green/amber/blue). +5. Copy: **"queued"** (not "going to be used"); replace "first/second/last" with the **trigger**; + keep mono instrument values + domain copy ("ended on signal", "awaiting lock"); show rate-of-change + deltas ("+14% over 6 min") and promote the decision-driving readout per tactic kind. + +### 2.3 Scenario test mode (the "test it for real experiments" requirement) +The renderer accepts an operation model from either the live endpoint **or** a **scenario fixture**. +A small dev affordance — `?scenario=` URL param (and/or a dev-only scenario ``) — loads -a named fixture from a **scenario library** (`gently/ui/web/static/js/operations-scenarios.js`): -`temp_strain`, `expression_onset`, `pre_hatching`, `transmission_survey`, `decided_plan`, `idle`. -This makes Operations developable/verifiable **offline against any tactic case**, with no rig. The -fixtures are the prototyped harness fixtures, promoted into the repo as test data + a Chrome-MCP -audit target. (Scenario mode is dev-gated; live mode is default.) - -### 2.4 Live refresh -The tab subscribes (via `ClientEventBus.on`, mirroring `experiment-strip.js`) to -`TEMP_PROTOCOL_STARTED/COMPLETED`, `TEMPERATURE_SETPOINT_CHANGED`, `BURST_START/COMPLETE`, -`EMBRYO_CADENCE_CHANGED`, `POWER_RAMP_STEP`, each triggering a debounced (~500 ms) refetch+render. The -websocket relay already emits all of these by `.name`; no backend change. +This single typed object expresses a regular timelapse (standing), an async per-embryo run (standing +with per-embryo cadence — *real* in the orchestrator), a reactive monitor incl. **hatching detection** +(reactive), a temp-change protocol (scripted, phased), an exclusive burst, and arbitrary `custom` +tactics — because the **agent declares each one's kind/structure**. + +## 2. Architecture (producer → model → consumer, all reusing existing paths) + +### 2.1 Producer — the `operation_plan` typed tool (agent) +A new agent tool `declare_operation_plan(...)` (or `update_operation_plan`) in `gently/app/tools/`, +schema auto-generated by the `@tool` decorator (or a forced literal schema like `notebook_ask.ASK_TOOL`). +The agent calls it at experiment planning and **patches it on each tactic transition** (begin / end / +phase-change). Prompt guidance (in `harness/prompts/templates.py`, beside the monitoring-mode decision +table) tells the agent to keep its Operation Plan current as it operates. + +### 2.2 Model — store the plan in `FileContextStore` +A new domain beside `session_intents`/`active/`: `agent/operation_plans/{session_id}.yaml`. Add +`set_operation_plan` / `get_operation_plan` to `FileContextStore` and fire the existing +`CONTEXT_UPDATED` (the store's `_notify_*` already broadcasts to `/ws`). The plan is an agent-authored +typed artifact exactly like campaigns/plan-items already are. + +### 2.3 Consumer — Operations renders plan ⊕ live binding +- Route `GET /api/operation_plan/{session_id}` (mirror `routes/context.py`) returns the plan. +- The renderer (the **operation spine**, generalized): one node per tactic in used/active/queued state. + The active node blooms open and **binds live telemetry** by `live_bind` keys — temperature gauge, + current burst, phase progress, cadence — sourced from the orchestrator `get_status` / + `strategy_snapshot` / the tactic events C emits. Per-embryo cadence (standing/async) shows as a + compact strip in the standing-tactic node. Reactive monitors render watch+reaction+status; scripted + protocols render the before/during/after stepper. +- Live refresh: subscribe to `CONTEXT_UPDATED` (plan changed) + the tactic telemetry events + (`TEMP_PROTOCOL_*`, `BURST_*`, `EMBRYO_CADENCE_CHANGED`, `POWER_RAMP_STEP`, `TEMPERATURE_SETPOINT_CHANGED`) + → debounced refetch+render. (Mirrors `experiment-strip.js` / `context-surface.js`.) +- **Audit fixes** (from the design audit) baked into the renderer: queued reads as a cocked instrument + (blue thread + "next" marker), the active phase/status is the headline, flatten the card (no + card-in-card), the active row's whole left column is amber + colored left edge per state, copy = + "queued", keep mono instrument values + domain copy. + +### 2.4 Scenario test mode (your "test it for real experiments" + "different tactic cases") +A repo scenario library of **Operation Plan fixtures** (the prototyped six cases, now as plan objects): +temp-strain, expression-onset, pre-hatching/hatching-detect, transmission survey, decided-plan, idle — +plus async-multi-embryo. `?scenario=` (dev-gated) renders a fixture with no agent/fetch, so +Operations is developable/auditable offline against any tactic case. The fixtures double as the test +corpus + a Chrome-MCP audit target. ## 3. Data flow -events → EventBus → websocket → `ClientEventBus` → debounced refetch → `/strategy` (now with -`operation`) → `build_operation_view` → spine renderer. In scenario mode: `?scenario=` → fixture → -same renderer (no fetch). - -## 4. Error/empty -- No active operation / no tactics → calm **idle** state ("When the agent begins operating by tactic, - the plan and live progress appear here"), not a dead screen. -- Snapshot lacks `operation` (older session) → renderer falls back to a minimal "no tactic data" note. -- Unknown tactic `kind` → renders generically from `name`/`summary`/`readouts` (the model is kind-agnostic). - -## 5. Testing -- Backend: `build_operation_view` over a hand-written snapshot → asserts the tactic list states + - the temp-protocol tactic's phases + a queued tactic's trigger. -- Frontend: `node --check`; the **scenario library** doubles as the test corpus — a Chrome-MCP audit - across all six fixtures (the harness, promoted); UI audit per the audit fixes. Live rig verification deferred. -- Scenario mode itself is the regression harness: any tactic case can be loaded and visually checked. - -## 6. Out of scope -- A general user-/agent-authored persisted tactic *library* (G) — D presents tactics; G lets users - define/save them. -- The Rules view rework; multi-embryo concurrent-operation layout (single active operation for now, - though the model + spine extend to a stack of operations later). +agent → `declare_operation_plan` (forced typed) → `FileContextStore` → `CONTEXT_UPDATED` → `/ws` → +Operations refetch → `/api/operation_plan` (the plan) **⊕** live telemetry (`get_status` / +`strategy_snapshot` / events) bound by `live_bind` → spine renderer. Scenario mode: `?scenario=` → +fixture plan → same renderer. + +## 4. Why agent-authored over backend reconstruction +`strategy_snapshot.py` already reconstructs the picture by replaying `timeline.jsonl` and re-instantiating +modes — but every new tactic kind needs new reconstruction heuristics, and it can only ever *infer* +intent. The agent *is* the composer; it should *declare* its tactics (authoritative, open-ontology, +self-describing). The backend keeps providing **live values** (what it's good at), not tactic identity. + +## 5. Convergence with G (tactics library) +Once tactics are typed, agent-authored objects, **G is "save/reuse these typed tactics"** and D is +"render the active plan of them." This spec is the shared substrate for both — settling it unblocks +both sub-projects. + +## 6. Testing +- Store: `set/get_operation_plan` round-trip + `CONTEXT_UPDATED` fired. +- Tool: the forced `declare_operation_plan` returns a schema-valid plan (mirror the notebook_ask test). +- Route: `/api/operation_plan/{session}` returns the stored plan; 404/empty handled. +- Renderer: `node --check` + Chrome-MCP audit across the scenario fixtures (all tactic kinds render). +- Live binding: a unit test that telemetry maps onto a tactic's `live_bind` keys. +- Rig-deferred: the real agent emitting a live plan during an experiment. + +## 7. Out of scope (v3) +- Full multi-embryo concurrent lanes (v1 = one standing mode + layered tactics + a per-embryo strip). +- G's authoring/save UI (this spec defines the tactic object G will reuse). +- Migrating away from `strategy_snapshot` for the legacy swimlane consumers (Operations is the new view). From 9d0c7d88d5013a4dd72445d7729f9b65cd327996 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:37:23 +0530 Subject: [PATCH 110/200] =?UTF-8?q?docs(operations):=20v3=20plan=20?= =?UTF-8?q?=E2=80=94=20agent-authored=20Operation=20Plan=20(5=20tasks,=20b?= =?UTF-8?q?ackend-first)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-28-operations-tab.md | 86 +++++++------------ 1 file changed, 33 insertions(+), 53 deletions(-) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index ab1768b2..e6a5c347 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -1,70 +1,50 @@ -# Operations — operation spine (D, redesigned) Implementation Plan +# Operations — agent-authored Operation Plan (D v3) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. -**Goal:** Replace the legacy swimlane with a data-driven **operation-spine** Operations view that shows tactics planned/in-use/used, renders any tactic case from a generic model, and is testable offline via scenario fixtures. +**Goal:** The agent emits a typed Operation Plan (its tactics, planned/active/done); Operations renders it ⊕ live telemetry. Built backend-first (the user-directed, low-rework part), then the renderer. -**Architecture:** A generic *operation* model (tactics with state + optional live readouts/phases); a rewritten `experiment-overview.js` overview = the spine renderer (audit fixes baked in); a repo scenario library (6 fixtures) + `?scenario=` dev mode; a backend `build_operation_view(snapshot)` transform served on `/strategy`; live event-driven refresh. +**Architecture:** A typed `OperationPlan` in `FileContextStore` (new domain), written by a forced-tool agent call, served on a route, rendered by a data-driven operation-spine generalized for tactic kinds, with live-telemetry binding + scenario test mode. -**Reference (the validated prototype — read it for the exact renderer, CSS, model, and fixtures):** -`/tmp/claude-1000/-home-dna-lab-projects-gently/3192c9a4-bc99-4c0a-9cbd-e094de730bcf/scratchpad/opsdesign/harness.html` +**Tech Stack:** Python (FileContextStore YAML, the `@tool`/forced-`tool_choice` pattern, EventBus `CONTEXT_UPDATED`), FastAPI route, vanilla-JS + SVG renderer, pytest. ## Global Constraints -- Keep the Experiment→Operations rename (done) + the burst-`phase` event (done). DISCARD the swimlane band. -- Operation model: `{title, session, tactics:[{seq, state:'used'|'active'|'queued', name, kind, target?, summary?, desc?, trigger?, live?:{readouts:[{label,value,sub?,bar?}], phases?:[{name,state:'done'|'active'|'todo',count,pips:[...]}]}}]}`. -- Audit fixes (baked into the renderer): (1) queued spine is colored blue + first queued marked "next" (decided-plan must read as a cocked instrument, not empty); (2) active phase dominates + "awaiting lock"-style status is the phase headline; (3) FLATTEN — no card-in-card, values on the panel face + hairline dividers; (4) active row's whole left column amber + colored left edge per state (green/amber/blue); (5) copy "queued" (not "going to be used"), replace "first/second/last" with `trigger`. Keep mono instrument values + domain copy. -- Renderer must handle ALL six fixtures (phased protocol, readout monitor, one-shot, all-queued, idle) without error. -- No new endpoint: extend `/api/experiments/{session}/strategy` payload with an `operation` object. -- Frontend has no JS unit harness → `node --check` + Chrome-MCP audit across fixtures. +- Source of truth = the agent's typed plan (NOT a backend reconstruction). Live telemetry binds onto declared tactics; the agent declares tactic identity, the system supplies live values. +- Plan schema (see spec §1): `{session_id,title,goal,tactics:[{id,name,kind,state,scope,rationale,structure,live_bind,relations}],updated_at,updated_reason}`. `kind∈{standing_timelapse,reactive_monitor,scripted_protocol,exclusive_burst,oneshot,custom}`, `state∈{planned,active,done}`. No round-robin. +- Forced typed output mirrors `gently/harness/memory/notebook_ask.py` (ASK_TOOL + `tool_choice={'type':'tool',...}` → validated `block.input`) OR the `@tool` auto-schema (`harness/tools/registry.py`). +- Store mirrors `session_intents`/`active/` domains; fire the existing `CONTEXT_UPDATED`. +- Route mirrors `gently/ui/web/routes/context.py` (`/api/context`). +- Renderer = the validated operation-spine (harness `scratchpad/opsdesign/harness.html`) with the audit fixes (queued reads cocked + "next" marker; active phase/status is the headline; flatten — no card-in-card; whole-left-column amber + colored left edge per state; copy "queued"; mono values). Generalize for the tactic kinds (standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phases). +- Frontend: no JS harness → `node --check` + Chrome-MCP audit across scenario fixtures. - Git hygiene: stage only your files by explicit path; never `git add -A`. --- -### Task 1: Discard the swimlane band -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (+ its css) — revert the tactic-band rendering added in commit 2a3d8b0, returning the Overview to its pre-band baseline (the spine replaces it in Task 2). Keep everything else (Rules view, modes, etc.). -- [ ] **Step 1:** `git revert --no-commit 2a3d8b0` (or manually remove the band code + css if revert conflicts), keeping the rename (Task-1) and burst-phase (Task-2) commits intact. Confirm `git diff` shows only the band removal. -- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). -- [ ] **Step 3: commit** — `git add gently/ui/web/static/js/experiment-overview.js && git commit -m "feat(operations): discard swimlane tactic-band (replaced by operation spine)"` +### Task 1: OperationPlan model + FileContextStore domain +**Files:** Create/extend `gently/harness/memory/model.py` (an `OperationPlan`/`Tactic` dataclass or a documented dict schema); Modify `gently/harness/memory/file_store.py` (`set_operation_plan(session_id, plan)` / `get_operation_plan(session_id)`, YAML under `agent/operation_plans/{session_id}.yaml`, fire `CONTEXT_UPDATED`). Test: `tests/test_operation_plan_store.py`. +- [ ] Confirm the real `FileContextStore` domain pattern from `set_session_intent`/`create_session_intent` (~file_store.py:758) + the `_notify_*`/`CONTEXT_UPDATED` emit. Mirror it. +- [ ] TDD: set→get round-trip preserves the tactics list + states; `CONTEXT_UPDATED` fired on set. `pytest tests/test_operation_plan_store.py -v`; `pytest -q` clean. Commit `feat(operations): OperationPlan store domain in FileContextStore`. ---- - -### Task 2: Scenario library + the operation-spine renderer (the core) -**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (the 6 fixtures, promoted from the harness); Modify `gently/ui/web/static/js/experiment-overview.js` (replace the Overview render path with the data-driven spine renderer from the harness, audit fixes applied) + the experiment css (spine/node/phase styles from the harness). Verify: `node --check` + Chrome-MCP audit. - -**Interfaces:** Produces `OperationView.render(rootEl, operationModel)` (or fold into `ExperimentOverview`); consumes the operation model (§ Global Constraints) from the live snapshot (Task 3) or a scenario fixture; `OPERATIONS_SCENARIOS` (the fixture map) + a `?scenario=` loader (dev mode). - -- [ ] **Step 1: port the fixtures** — create `operations-scenarios.js` exposing `window.OPERATIONS_SCENARIOS = {temp_strain, expression_onset, pre_hatching, transmission_survey, decided_plan, idle}` — copy the fixture objects from the harness `SCENARIOS` (the `.op` values), as the repo test corpus. Apply the copy fixes: `state:'queued'` (not 'planned' label), add `trigger` fields ("when temp locks", "after #1", etc.) replacing "first/second/last". -- [ ] **Step 2: the renderer** — port the harness renderer (`renderOperation`/`renderTactic`/`renderReadout`/`renderPhase`) into `experiment-overview.js`, replacing the swimlane Overview path, WITH the audit fixes: state-colored spine connector (green/amber/blue-dashed), whole-left-column amber for active, colored left edge per state, flattened active card (remove inner card backgrounds; hairline divider between readouts and phases), active phase dominant + status headline, first-queued "next" marker, `STATE_LABEL.queued='queued'`. Keep mono labels. -- [ ] **Step 3: scenario dev mode** — on init, if `new URLSearchParams(location.search).get('scenario')` matches a fixture, render that fixture (no fetch); else fetch the live operation (Task 3). Guard so scenario mode is opt-in via the param. -- [ ] **Step 4: `node --check`** both JS files (exit 0). -- [ ] **Step 5: Chrome-MCP audit** — serve the static files, load `?scenario=temp_strain`, `expression_onset`, `decided_plan`, `idle`; screenshot each; verify the audit fixes (queued reads cocked, active dominates, flattened, amber left column, "queued" copy) and that all six render without error; fix flaws. -- [ ] **Step 6: commit** — `feat(operations): operation-spine renderer + scenario library (data-driven, 6 cases)` - ---- +### Task 2: `declare_operation_plan` agent tool (forced typed output) +**Files:** Create `gently/app/tools/operation_plan_tools.py` (+ register in `tools/__init__`). Test: `tests/test_operation_plan_tool.py`. +- [ ] Confirm the `@tool` decorator + the forced-tool pattern (registry.py auto-schema, or a literal schema like notebook_ask.ASK_TOOL). The tool accepts the typed plan (tactics list) and writes it via `store.set_operation_plan`. Resolve the store from context (mirror existing memory tools). +- [ ] TDD: calling the tool with a plan persists it (get returns it) + returns a confirmation; missing store → error. `pytest tests/test_operation_plan_tool.py -v`; `pytest -q` clean. Commit `feat(operations): declare_operation_plan typed agent tool`. -### Task 3: Backend transform `build_operation_view` -**Files:** Modify `gently/ui/web/strategy_snapshot.py` (add `build_operation_view(snapshot)` mapping the snapshot → operation model; have `build_strategy_snapshot` include `operation` in its return, OR add it in the route). Test: `tests/test_operation_view.py`. +### Task 3: Route `GET /api/operation_plan/{session_id}` +**Files:** Create `gently/ui/web/routes/operation_plan.py` (+ register in `routes/__init__.py`). Test: `tests/test_operation_plan_route.py`. +- [ ] Mirror `routes/context.py` / `routes/temperature.py` `_resolve_session` + `server.context_store`/`gently_store` resolution. Return the stored plan; 404/empty handled; `session=current` resolves newest. +- [ ] TDD (TestClient + mock store): returns the plan; empty when none. `pytest tests/test_operation_plan_route.py -v`; `pytest -q` clean. Commit `feat(operations): GET /api/operation_plan/{session} route`. -**Interfaces:** Produces `build_operation_view(snapshot: dict) -> dict` (the operation model); `/api/experiments/{session}/strategy` response gains `operation`. +### Task 4: The operation-spine renderer + scenario library (frontend) +**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle); Rewrite the Overview path in `gently/ui/web/static/js/experiment-overview.js` to the data-driven operation-spine (generalized per tactic kind, audit fixes); port CSS to `experiment.css`. Reference: `scratchpad/opsdesign/harness.html`. +- [ ] Port the renderer (renderOperation/renderTactic/renderReadout/renderPhase) + add per-kind rendering: standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phase stepper; exclusive/oneshot→compact. Audit fixes baked in. `?scenario=` dev mode loads a fixture. +- [ ] `node --check` both JS; build a Chrome-MCP harness at `scratchpad/opsv3/` (real repo files) for the controller to audit across fixtures. Commit `feat(operations): operation-spine renderer + plan scenario library (data-driven)`. -- [ ] **Step 1: failing test** — feed a snapshot dict (one embryo with a `temp_protocol` band {start,end,target_setpoint_c}, `setpoint_changes`, phased bursts, and an active monitoring mode) to `build_operation_view`; assert the returned `operation.tactics` includes: a `used` monitor tactic, an `active` temp-change tactic whose `live.phases` reflect before/during/after, and the `state`s are correct. (Confirm the real snapshot shape from `tests/test_temp_protocol_snapshot.py`.) -- [ ] **Step 2: run, FAIL.** -- [ ] **Step 3: implement** `build_operation_view`: derive tactics from the snapshot — completed monitoring modes/bursts → `used`; the active `temp_protocol`/monitoring mode → `active` (with readouts from temp + current burst, phases from the protocol's before/during/after burst counts); planned/queued items → `queued`. Map fields into the §model. Include `operation` in the snapshot/route payload. -- [ ] **Step 4: run, PASS;** `pytest -q` clean. -- [ ] **Step 5: commit** — `feat(operations): build_operation_view transform on the strategy snapshot` - ---- - -### Task 4: Live refresh -**Files:** Modify `experiment-overview.js` (`init` — subscribe + debounced refetch, mirroring `experiment-strip.js`). Verify: `node --check` + harness (simulate an event → refetch). -- [ ] **Step 1:** in `init`, after the initial live fetch, register `ClientEventBus.on(...)` for `TEMP_PROTOCOL_STARTED`,`TEMP_PROTOCOL_COMPLETED`,`TEMPERATURE_SETPOINT_CHANGED`,`BURST_START`,`BURST_COMPLETE`,`EMBRYO_CADENCE_CHANGED`,`POWER_RAMP_STEP`, each → a debounced (~500ms) refetch+render. Guard against double-registration; skip in scenario mode. -- [ ] **Step 2:** `node --check` (exit 0). -- [ ] **Step 3: harness** — dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch fires after the debounce. -- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh` - ---- +### Task 5: Live binding + refresh +**Files:** Modify `experiment-overview.js` (fetch `/api/operation_plan`, bind live telemetry from `/strategy`/get_status onto tactics by `live_bind`, subscribe `CONTEXT_UPDATED` + tactic events → debounced refetch). +- [ ] Bind temperature/current-burst/cadence/signal onto declared tactics' readouts/progress; live refresh on plan-change + telemetry events (debounced). `node --check`; harness check. Commit `feat(operations): live telemetry binding + event-driven refresh`. ## Self-Review -- Discard swimlane → Task 1; renderer+scenarios → Task 2 (audit fixes baked in); backend transform → Task 3; live refresh → Task 4. ✓ -- Open confirmations: the real snapshot shape (Task 3, from C's snapshot test); the `experiment-overview.js` Overview render seam to replace (Task 2, read the harness for the target renderer); the css file for experiment styles (Task 1/2). -- Type consistency: the operation model fields are identical across the fixtures (Task 2), the transform (Task 3), and the renderer (Task 2). +- Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓ +- Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5). +- Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5). From 192fe97e5fe084dfa296b5e728e84d29306ebaeb Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:41:21 +0530 Subject: [PATCH 111/200] feat(operations): OperationPlan store domain in FileContextStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add set_operation_plan / get_operation_plan to FileContextStore under agent/operation_plans/{session_id}.yaml, mirroring the session_intents domain (atomic _write_yaml, parse-cached _read_yaml). set fires _notify_context_change('operation_plan') → CONTEXT_UPDATED so the Operations UI live-refreshes. 10 tests: round-trip fidelity (tactics list, states, nested structure, overwrite, independent sessions, missing→None) + CONTEXT_UPDATED fired on set (kind='operation_plan') and silent on get. --- gently/harness/memory/file_store.py | 19 +++ tests/test_operation_plan_store.py | 173 ++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 tests/test_operation_plan_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index ececeba2..0e831c12 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -811,6 +811,25 @@ def complete_session_intent(self, session_id: str, actual_summary: str): data["completed_at"] = self._now() self._write_yaml(path, data) + # ================================================================== + # Operation Plans + # ================================================================== + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + """Persist the agent-authored Operation Plan for a session. + + The plan dict is stored verbatim (agent is the source of truth). + Fires CONTEXT_UPDATED so the Operations UI refreshes live. + """ + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + self._write_yaml(path, plan) + self._notify_context_change("operation_plan") + + def get_operation_plan(self, session_id: str) -> dict | None: + """Return the stored Operation Plan for a session, or None if absent.""" + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + return self._read_yaml(path) + # ================================================================== # Session <-> Campaign (many-to-many) # ================================================================== diff --git a/tests/test_operation_plan_store.py b/tests/test_operation_plan_store.py new file mode 100644 index 00000000..c656e410 --- /dev/null +++ b/tests/test_operation_plan_store.py @@ -0,0 +1,173 @@ +""" +FileContextStore: OperationPlan domain — set/get round-trip + CONTEXT_UPDATED event. + +Domain: agent/operation_plans/{session_id}.yaml +Methods: set_operation_plan / get_operation_plan +""" + +from gently.core.event_bus import EventType, on + + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_001", + "title": "Temperature-strain protocol", + "goal": "Characterise onset timing under 25 °C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": { + "cadence_s": 120, + "per_embryo": [ + {"embryo_id": "e1", "cadence_phase": "normal", "interval_s": 120} + ], + }, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset event triggered by ramp", + "structure": { + "watch": "temperature > 24.5", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["temperature", "signal"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Post-ramp survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp completion", + "structure": {"note": "single high-res z-stack per embryo"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, + ], + "updated_at": "2026-06-28T10:00:00", + "updated_reason": "initial plan", +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestOperationPlanRoundTrip: + def test_set_then_get_returns_plan(self, file_context_store): + """set_operation_plan followed by get_operation_plan returns the full dict.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + assert result is not None + assert result["session_id"] == "sess_001" + assert result["title"] == "Temperature-strain protocol" + + def test_tactics_list_preserved(self, file_context_store): + """All three tactics are returned with their ids and kinds intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + tactics = result["tactics"] + assert len(tactics) == 3 + ids = [t["id"] for t in tactics] + assert ids == ["t1", "t2", "t3"] + kinds = [t["kind"] for t in tactics] + assert kinds == ["standing_timelapse", "reactive_monitor", "oneshot"] + + def test_tactic_states_preserved(self, file_context_store): + """State values (done / active / planned) survive the YAML round-trip.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + states = {t["id"]: t["state"] for t in result["tactics"]} + assert states == {"t1": "done", "t2": "active", "t3": "planned"} + + def test_nested_structure_preserved(self, file_context_store): + """Nested fields (scope, structure, live_bind, relations) survive intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + t2 = next(t for t in result["tactics"] if t["id"] == "t2") + assert t2["structure"]["status"] == "armed" + assert "temperature" in t2["live_bind"] + assert t2["relations"]["after"] == ["t1"] + + def test_get_missing_returns_none(self, file_context_store): + """get_operation_plan returns None when no plan exists for the session.""" + result = file_context_store.get_operation_plan("no_such_session") + assert result is None + + def test_overwrite_replaces_plan(self, file_context_store): + """A second set_operation_plan replaces the first.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + updated = dict(_PLAN, title="Revised plan", updated_reason="tactic t2 fired") + cs.set_operation_plan("sess_001", updated) + result = cs.get_operation_plan("sess_001") + assert result["title"] == "Revised plan" + assert result["updated_reason"] == "tactic t2 fired" + + def test_independent_sessions(self, file_context_store): + """Plans for different session_ids are stored independently.""" + cs = file_context_store + plan_a = dict(_PLAN, session_id="sess_A", title="Plan A") + plan_b = dict(_PLAN, session_id="sess_B", title="Plan B") + cs.set_operation_plan("sess_A", plan_a) + cs.set_operation_plan("sess_B", plan_b) + assert cs.get_operation_plan("sess_A")["title"] == "Plan A" + assert cs.get_operation_plan("sess_B")["title"] == "Plan B" + + +class TestOperationPlanContextUpdatedEvent: + def test_set_fires_context_updated(self, file_context_store): + """set_operation_plan emits CONTEXT_UPDATED on the global event bus.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + assert len(seen) >= 1 + + def test_context_updated_kind_is_operation_plan(self, file_context_store): + """The CONTEXT_UPDATED event carries kind='operation_plan'.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + kinds = [(e.data or {}).get("kind") for e in seen] + assert "operation_plan" in kinds + + def test_get_does_not_fire_event(self, file_context_store): + """get_operation_plan is read-only and must not emit CONTEXT_UPDATED.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.get_operation_plan("sess_001") + finally: + unsub() + assert len(seen) == 0 From 1472dd086d5c8c61edad6a87c603690140cf2318 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:51:39 +0530 Subject: [PATCH 112/200] feat(operations): declare_operation_plan typed agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the declare_operation_plan @tool for the agent to author its typed Operation Plan (tactics: planned/active/done) per experiment session. - gently/app/tools/operation_plan_tools.py: @tool decorator, signature (title, goal, tactics, updated_reason, context), _validate_tactics helper (clamps unknown kinds to 'custom', rejects invalid states), stamps updated_at, resolves session_id + context_store via agent in context, calls store.set_operation_plan(session_id, plan). - gently/app/tools/__init__.py: registers operation_plan_tools module. - tests/test_operation_plan_tool.py: 15 TDD tests — happy path round-trip, tactics preserved, unknown-kind clamping, three error paths (no agent, no store, no session), two validation-error paths, registry discoverability, schema required-fields check. --- gently/app/tools/__init__.py | 2 + gently/app/tools/operation_plan_tools.py | 195 +++++++++++ tests/test_operation_plan_tool.py | 424 +++++++++++++++++++++++ 3 files changed, 621 insertions(+) create mode 100644 gently/app/tools/operation_plan_tools.py create mode 100644 tests/test_operation_plan_tool.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 16d7aebb..b3b31d1b 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -38,6 +38,7 @@ led_tools, light_source_tools, memory_tools, + operation_plan_tools, plan_execution_tools, resolution_tools, session_tools, @@ -85,6 +86,7 @@ def register_all_tools(): "led_tools", "light_source_tools", "memory_tools", + "operation_plan_tools", "plan_execution_tools", "resolution_tools", "session_tools", diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py new file mode 100644 index 00000000..07d51835 --- /dev/null +++ b/gently/app/tools/operation_plan_tools.py @@ -0,0 +1,195 @@ +""" +Operation Plan tool — lets the agent declare/update its typed Operation Plan. + +The agent calls this at experiment planning time to register the tactics it +intends to run, and calls it again on each tactic transition (planned → +active → done). The plan is stored in FileContextStore and fires +CONTEXT_UPDATED so the Operations UI refreshes live. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + +# Valid kind / state values per §1 of the spec +_VALID_KINDS = frozenset( + { + "standing_timelapse", + "reactive_monitor", + "scripted_protocol", + "exclusive_burst", + "oneshot", + "custom", + } +) +_VALID_STATES = frozenset({"planned", "active", "done"}) + + +def _validate_tactics(tactics: list) -> list[dict]: + """Validate and normalise a tactics list. + + Each tactic must have id, name, kind, state. + Unknown kinds are clamped to 'custom'. + Invalid states raise ValueError. + """ + validated = [] + for i, t in enumerate(tactics): + if not isinstance(t, dict): + raise ValueError(f"Tactic at index {i} must be a dict, got {type(t).__name__}") + for required_field in ("id", "name", "kind", "state"): + if not t.get(required_field): + raise ValueError(f"Tactic at index {i} missing required field '{required_field}'") + tactic = dict(t) + kind = tactic["kind"] + if kind not in _VALID_KINDS: + logger.warning( + "Tactic '%s' has unknown kind '%s' — clamped to 'custom'", + tactic.get("id"), + kind, + ) + tactic["kind"] = "custom" + state = tactic["state"] + if state not in _VALID_STATES: + raise ValueError( + f"Tactic '{tactic.get('id')}' has invalid state '{state}'. " + f"Must be one of: {sorted(_VALID_STATES)}" + ) + validated.append(tactic) + return validated + + +@tool( + name="declare_operation_plan", + description=( + "Declare or update the Operation Plan for the current experiment session. " + "Call this at experiment planning time to register the tactics you intend to " + "run, and call it again whenever a tactic's state changes (planned → active → " + "done) or a new tactic is added. " + "kind ∈ {standing_timelapse, reactive_monitor, scripted_protocol, " + "exclusive_burst, oneshot, custom}. " + "state ∈ {planned, active, done}. " + "Unknown kinds are clamped to 'custom'." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Declare my experiment plan: baseline timelapse + onset monitor", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + ], + "updated_reason": "experiment started", + }, + ), + ToolExample( + user_query="Update the plan: baseline is done, onset monitor is now active", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Watch for GFP signal crossing threshold", + "structure": { + "watch": "gfp_signal > 0.3", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_reason": "t1 done, t2 now active", + }, + ), + ], +) +async def declare_operation_plan( + title: str, + goal: str, + tactics: list, + updated_reason: str = "", + context: dict | None = None, +) -> str: + """Declare or update the typed Operation Plan for this session. + + Parameters + ---------- + title : str + Short title for this experiment's plan. + goal : str + The agent's framing of what this run is trying to achieve. + tactics : list + List of tactic dicts. Each must have id, name, kind, state. + updated_reason : str + Why the plan is being updated (e.g. 't2 transitioned to active'). + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot persist Operation Plan" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot persist Operation Plan" + + try: + validated_tactics = _validate_tactics(tactics) + except ValueError as e: + return f"Error: {e}" + + plan = { + "session_id": session_id, + "title": title, + "goal": goal, + "tactics": validated_tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": updated_reason, + } + + cs.set_operation_plan(session_id, plan) + + n = len(validated_tactics) + counts = {s: sum(1 for t in validated_tactics if t["state"] == s) for s in _VALID_STATES} + parts = [f"{s}:{c}" for s, c in counts.items() if c] + states_summary = ", ".join(parts) + return ( + f"Operation Plan stored for session {session_id}: " + f"'{title}' — {n} tactic{'s' if n != 1 else ''} ({states_summary})" + ) diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py new file mode 100644 index 00000000..ad6804d6 --- /dev/null +++ b/tests/test_operation_plan_tool.py @@ -0,0 +1,424 @@ +""" +Tests for the declare_operation_plan agent tool (Task 2). + +TDD — three concerns: + 1. Happy path: calling the tool persists the plan; store returns it. + 2. Error paths: missing store, missing session → error string, no write. + 3. Registration: tool is discoverable in the global registry after import. +""" + +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal in-memory stand-in for FileContextStore.""" + + def __init__(self): + self._plans: dict[str, dict] = {} + self.calls: list[tuple] = [] + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = plan + self.calls.append(("set", session_id, plan)) + + def get_operation_plan(self, session_id: str) -> dict | None: + return self._plans.get(session_id) + + +class FakeAgent: + """Minimal fake agent — carries context_store and session_id.""" + + def __init__(self, *, context_store=None, session_id: str | None = "sess_test_01"): + self.context_store = context_store + self.session_id = session_id + + +def _make_context(*, with_store=True, with_session=True): + store = FakeContextStore() if with_store else None + sid = "sess_test_01" if with_session else None + agent = FakeAgent(context_store=store, session_id=sid) + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Minimal tactic fixture +# --------------------------------------------------------------------------- + +_TACTICS_MINIMAL = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } +] + +_TACTICS_MULTI = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset crossing threshold", + "structure": {"watch": "gfp_signal > 0.3", "reaction": "burst_capture", "status": "armed"}, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Final survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp", + "structure": {"note": "single high-res z-stack"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, +] + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_plan_is_persisted_to_store(): + """Calling the tool with a valid plan stores it; get_operation_plan returns it.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Expression-onset survey", + goal="Catch GFP onset under 25 C ramp", + tactics=_TACTICS_MINIMAL, + updated_reason="experiment started", + context=ctx, + ) + + store = ctx["agent"].context_store + stored = store.get_operation_plan("sess_test_01") + assert stored is not None, "Plan should have been persisted" + assert stored["title"] == "Expression-onset survey" + assert stored["goal"] == "Catch GFP onset under 25 C ramp" + assert stored["session_id"] == "sess_test_01" + assert "Error" not in result + + +@pytest.mark.asyncio +async def test_confirmation_string_contains_session_and_title(): + """The return value identifies the session and plan title.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Temperature-strain protocol", + goal="Characterise onset timing under 25 C ramp", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "sess_test_01" in result + assert "Temperature-strain protocol" in result + + +@pytest.mark.asyncio +async def test_tactics_list_preserved_in_store(): + """All tactics are stored with correct ids, kinds, and states.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Multi-tactic plan", + goal="Three-phase experiment", + tactics=_TACTICS_MULTI, + updated_reason="t1 done, t2 active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + tactics = stored["tactics"] + assert len(tactics) == 3 + assert [t["id"] for t in tactics] == ["t1", "t2", "t3"] + assert [t["state"] for t in tactics] == ["done", "active", "planned"] + assert [t["kind"] for t in tactics] == ["standing_timelapse", "reactive_monitor", "oneshot"] + + +@pytest.mark.asyncio +async def test_updated_at_is_stamped(): + """The plan stored in the store has a non-empty updated_at timestamp.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + assert stored.get("updated_at"), "updated_at should be stamped" + + +@pytest.mark.asyncio +async def test_updated_reason_stored(): + """updated_reason is carried into the stored plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + updated_reason="tactic t1 transitioned to active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["updated_reason"] == "tactic t1 transitioned to active" + + +@pytest.mark.asyncio +async def test_set_called_exactly_once(): + """set_operation_plan is called exactly once per tool invocation.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + store = ctx["agent"].context_store + set_calls = [c for c in store.calls if c[0] == "set"] + assert len(set_calls) == 1 + + +@pytest.mark.asyncio +async def test_overwrite_replaces_plan(): + """A second call replaces the first plan in the store.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Initial plan", + goal="First framing", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + await declare_operation_plan( + title="Revised plan", + goal="Updated framing", + tactics=_TACTICS_MULTI, + updated_reason="added two tactics", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["title"] == "Revised plan" + assert len(stored["tactics"]) == 3 + + +# --------------------------------------------------------------------------- +# Unknown kind → clamped to 'custom' +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_unknown_kind_clamped_to_custom(): + """A tactic with an unrecognised kind is silently clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + tactics = [ + { + "id": "t1", + "name": "Novel tactic", + "kind": "future_kind_v99", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Something new", + "structure": {"note": "TBD"}, + "live_bind": [], + "relations": {}, + } + ] + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=tactics, + context=ctx, + ) + + assert "Error" not in result + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["tactics"][0]["kind"] == "custom" + + +# --------------------------------------------------------------------------- +# Error paths — no write must occur +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_agent_returns_error_no_write(): + """No agent in context → error string, store is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + # No agent key at all + ctx: dict = {} + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "error" in result.lower() or "Error" in result + + +@pytest.mark.asyncio +async def test_missing_store_returns_error_no_write(): + """Agent has no context_store → error string, nothing persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_store=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store is None # store was never created + + +@pytest.mark.asyncio +async def test_missing_session_id_returns_error_no_write(): + """Agent has no session_id → error string, store.set is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_session=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + store = ctx["agent"].context_store + assert store.calls == [], "set_operation_plan must not be called without a session_id" + + +# --------------------------------------------------------------------------- +# Validation errors — no write on invalid tactics +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_missing_tactic_id_returns_error_no_write(): + """A tactic missing 'id' causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [{"name": "No id", "kind": "oneshot", "state": "planned"}] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +@pytest.mark.asyncio +async def test_invalid_state_returns_error_no_write(): + """A tactic with an invalid state causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [ + { + "id": "t1", + "name": "Bad state", + "kind": "oneshot", + "state": "running", # not a valid state + } + ] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +# --------------------------------------------------------------------------- +# Registration test +# --------------------------------------------------------------------------- + + +def test_tool_is_registered_in_global_registry(): + """declare_operation_plan is discoverable via the global tool registry after import.""" + import gently.app.tools # noqa: F401 — triggers registration side effects + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = [t.name for t in registry.list_all()] + assert "declare_operation_plan" in names, ( + f"Tool not found in registry. Registered tools: {names}" + ) + + +def test_tool_schema_has_required_fields(): + """The auto-generated Claude schema has the expected required fields.""" + import gently.app.tools # noqa: F401 + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_def = registry.get("declare_operation_plan") + assert tool_def is not None + + schema = tool_def.to_claude_schema() + assert schema["name"] == "declare_operation_plan" + required = schema["input_schema"]["required"] + assert "title" in required + assert "goal" in required + assert "tactics" in required + # updated_reason has a default, so it must NOT be in required + assert "updated_reason" not in required From f0d09bd0621d26b67b8e924c16e7298538542ae8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:52:46 +0530 Subject: [PATCH 113/200] docs(operations): add execution-linkage tasks (transition_tactic, tool start-edges, OperationPlanUpdater) --- .../plans/2026-06-28-operations-tab.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index e6a5c347..208d642d 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -48,3 +48,18 @@ - Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓ - Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5). - Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5). + +--- +## Execution-linkage tasks (added after recon — close the planning→execution loop) + +### Task 6: `transition_tactic` store helper +**Files:** Modify `gently/harness/memory/file_store.py` (add `transition_tactic(session_id, tactic_id, state=None, **bind)` next to `set/get_operation_plan` ~:818 — read the plan, find the tactic by `id`, set `state` and merge `bind` into its `live`/`structure`, write back, fire `CONTEXT_UPDATED`; no-op if plan/tactic absent). Test: `tests/test_transition_tactic.py`. +- [ ] TDD: declare a plan, transition a tactic planned→active with a `request_id` bind → get shows the new state + bound value; unknown tactic_id → no-op (no crash). Commit `feat(operations): transition_tactic store helper`. + +### Task 7: tactic_id threading + start-edge marking in execution tools +**Files:** Modify `gently/app/tools/timelapse_tools.py` (`enable_monitoring_mode`, `queue_burst`, stop/pause), `gently/app/tools/temperature_protocol_tools.py`, and the burst/protocol event payloads (`exclusive.py` BURST_*, `temperature_protocol.py` TEMP_PROTOCOL_*) to carry an optional `tactic_id`. On execute, the tool calls `cs.transition_tactic(session, tactic_id, 'active')`; on stop/pause → 'done'/'paused'. Test: `tests/test_tool_tactic_linkage.py`. +- [ ] Add optional `tactic_id` param; thread into event `data`; flip the plan tactic active on execute (guard: only if a plan + tactic_id exist). TDD with a fake context store capturing transitions. Commit `feat(operations): link execution tools to plan tactics via tactic_id`. + +### Task 8: `OperationPlanUpdater` service (completion edges via the bus) +**Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`. +- [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`. From a75d23ff3abb89fa6673dfe065a9200047a91486 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:57:05 +0530 Subject: [PATCH 114/200] feat(operations): GET /api/operation_plan/{session} route Adds read-only route returning the agent-authored Operation Plan from FileContextStore.get_operation_plan(). "current" resolves via gently_store.list_sessions()[0]. Returns {available, plan, session_id}; graceful unavailable when store is absent or plan not yet written. Registered in routes/__init__.py alongside other route factories. --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/operation_plan.py | 40 ++++++++++++ tests/test_operation_plan_route.py | 87 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 gently/ui/web/routes/operation_plan.py create mode 100644 tests/test_operation_plan_route.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 66e8842c..6cd54a7a 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,6 +13,7 @@ from .context import create_router as create_context_router from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router +from .operation_plan import create_router as create_operation_plan_router from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router from .notebook import create_router as create_notebook_router @@ -39,6 +40,7 @@ def register_all_routes(server): create_context_router, create_notebook_router, create_temperature_router, + create_operation_plan_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/operation_plan.py b/gently/ui/web/routes/operation_plan.py new file mode 100644 index 00000000..9e0d8c82 --- /dev/null +++ b/gently/ui/web/routes/operation_plan.py @@ -0,0 +1,40 @@ +"""Operation Plan route. + +Returns the agent-authored Operation Plan for a session. +The plan is stored in FileContextStore (``server.context_store``) keyed by +session_id. ``session_id="current"`` resolves to the newest session via the +FileStore (``server.gently_store``). +""" + +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str) -> str: + if session_id == "current": + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + return session_id + + @router.get("/api/operation_plan/{session_id}") + async def get_operation_plan(session_id: str): + real_id = _resolve_session(session_id) + cs = getattr(server, "context_store", None) + if cs is None: + return {"session_id": real_id, "available": False, "plan": None} + try: + plan = cs.get_operation_plan(real_id) + except Exception: + plan = None + if plan is None: + return {"session_id": real_id, "available": False, "plan": None} + return {"session_id": real_id, "available": True, "plan": plan} + + return router diff --git a/tests/test_operation_plan_route.py b/tests/test_operation_plan_route.py new file mode 100644 index 00000000..c7c45d59 --- /dev/null +++ b/tests/test_operation_plan_route.py @@ -0,0 +1,87 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.operation_plan import create_router + +_SAMPLE_PLAN = { + "tactics": [ + {"id": "t1", "description": "Start acquisition", "status": "done"}, + {"id": "t2", "description": "Monitor drift", "status": "active"}, + ] +} + + +def _server(plan, sessions=(("sess-1",),), context_store_available=True): + gently_store = MagicMock() + gently_store.list_sessions.return_value = [{"session_id": sid} for (sid,) in sessions] + + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.get_operation_plan.return_value = plan + + srv = MagicMock() + srv.gently_store = gently_store + srv.context_store = context_store + return srv, gently_store, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_stored_plan(): + srv, _, cs = _server(_SAMPLE_PLAN) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["session_id"] == "sess-1" + assert body["plan"]["tactics"][0]["id"] == "t1" + cs.get_operation_plan.assert_called_with("sess-1") + + +def test_returns_unavailable_when_no_plan(): + srv, _, cs = _server(None) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_current_resolves_newest(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=(("newest-session",),)) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "newest-session" + cs.get_operation_plan.assert_called_with("newest-session") + + +def test_current_no_sessions_returns_404(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=()) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 404 + + +def test_context_store_unavailable_returns_unavailable(): + srv, _, _ = _server(_SAMPLE_PLAN, context_store_available=False) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_context_store_exception_returns_unavailable(): + srv, _, cs = _server(_SAMPLE_PLAN) + cs.get_operation_plan.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False From 84f5f17af21404e07118ad18438e993fe54016e7 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:58:41 +0530 Subject: [PATCH 115/200] =?UTF-8?q?docs(operations):=20add=20plan-item?= =?UTF-8?q?=E2=86=94operation=20linkage=20tasks=20(tactical=20outline=20+?= =?UTF-8?q?=20goal/seeding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-06-28-operations-tab.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index 208d642d..46625492 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -63,3 +63,14 @@ ### Task 8: `OperationPlanUpdater` service (completion edges via the bus) **Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`. - [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`. + +--- +## Plan-item ↔ operation linkage tasks (added — tactics planned at plan time) + +### Task 9: PlanItem/ImagingSpec tactical outline +**Files:** Modify `gently/harness/memory/model.py` (`ImagingSpec` ~:200 / `PlanItem` ~:265 — add optional `tactics: list[dict]` outline field: each entry a lightweight tactic `{kind, name, target?, scope?, structure?}`). Ensure the plan-mode planning tools (`gently/harness/plan_mode/tools/planning.py` create_plan_item/update_plan_item) accept/persist it. Test: extend the plan-item model/store tests. +- [ ] Confirm the real ImagingSpec/PlanItem dataclass + how planning tools set the spec. Add the optional `tactics` outline (default empty), persisted in the campaign plan YAML. TDD: a plan item round-trips its tactics outline. Commit `feat(operations): plan-item tactical outline (plan tactics with the imaging spec)`. + +### Task 10: Operation Plan goal/plan_item linkage + seeding +**Files:** Modify `gently/app/tools/operation_plan_tools.py` (or a small seeding helper / the agent): resolve the current session's `plan_item_id`/`campaign_id`/goal from the `session_intent` (`get_current_session_intent` ~file_store.py:788) → set them on the Operation Plan top-level; when a session linked to a plan item with a `tactics` outline begins (or on first declare), SEED the Operation Plan's `planned` tactics from the outline. Test: `tests/test_operation_plan_seeding.py`. +- [ ] Confirm the session_intent→plan_item linkage accessors. On declare/seed, populate `plan_item_id`/`campaign_id`/`goal` from the linked plan item and seed `planned` tactics from its outline (idempotent — don't clobber tactics already active/done). TDD with a fake store: a session linked to a plan item with an outline produces a seeded Operation Plan. Commit `feat(operations): seed Operation Plan goal + planned tactics from the linked plan item`. From 66a40d132c58c3bd6a2079f4e674036ef1311174 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:02:33 +0530 Subject: [PATCH 116/200] feat(operations): transition_tactic store helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FileContextStore.transition_tactic(session_id, tactic_id, state=None, **bind) — reads the plan via get_operation_plan, finds the tactic by id, flips its state, merges bind kwargs into tactic['live'] (creating the dict if absent), stamps updated_at/updated_reason, and writes back via set_operation_plan so CONTEXT_UPDATED fires exactly once. Returns True on success, False (no-op) when plan or tactic is absent. 18 TDD tests all pass. --- gently/harness/memory/file_store.py | 35 +++++ tests/test_transition_tactic.py | 230 ++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 tests/test_transition_tactic.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 0e831c12..98763cf3 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -830,6 +830,41 @@ def get_operation_plan(self, session_id: str) -> dict | None: path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" return self._read_yaml(path) + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + """Atomically update a tactic's state and/or bind live values onto it. + + Reads the plan via get_operation_plan, locates the tactic by id, + sets its state (if provided), merges bind kwargs into its live dict + (creating it if absent), stamps updated_at/updated_reason, and writes + back via set_operation_plan so CONTEXT_UPDATED fires exactly once. + + Returns True on success, False if the plan is absent or the tactic id + is not found (no-op, no crash). + """ + plan = self.get_operation_plan(session_id) + if plan is None: + return False + + tactics = plan.get("tactics", []) + tactic = next((t for t in tactics if t.get("id") == tactic_id), None) + if tactic is None: + return False + + if state is not None: + tactic["state"] = state + + if bind: + live = tactic.setdefault("live", {}) + live.update(bind) + + plan["updated_at"] = self._now() + plan["updated_reason"] = f"tactic {tactic_id} transitioned" + + self.set_operation_plan(session_id, plan) + return True + # ================================================================== # Session <-> Campaign (many-to-many) # ================================================================== diff --git a/tests/test_transition_tactic.py b/tests/test_transition_tactic.py new file mode 100644 index 00000000..5b72648a --- /dev/null +++ b/tests/test_transition_tactic.py @@ -0,0 +1,230 @@ +""" +FileContextStore.transition_tactic — TDD tests. + +Verifies: state flip, live bind merge, updated_at stamp, +True/False return values, no-op on missing plan or tactic. +""" + +import pytest +from gently.core.event_bus import EventType, on + + +# --------------------------------------------------------------------------- +# Plan fixture shared across tests +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_tt", + "title": "Transition-tactic test plan", + "goal": "Verify atomic tactic transitions", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Detect onset event", + "structure": {"watch": "temperature > 24.5"}, + "live_bind": ["temperature"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_at": "2026-06-28T00:00:00", + "updated_reason": "initial plan", +} + + +def _fresh_plan(): + """Deep-copy the fixture so mutations don't bleed between tests.""" + import copy + return copy.deepcopy(_PLAN) + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticHappyPath: + def test_returns_true_on_success(self, file_context_store): + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "t1", state="active") + assert result is True + + def test_state_updated(self, file_context_store): + """State flips from planned to active after transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + + def test_bind_written_to_live(self, file_context_store): + """Bind kwargs land in tactic['live'].""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + + def test_state_and_bind_together(self, file_context_store): + """State flip and live bind work together in one call.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1", cadence=90) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["cadence"] == 90 + + def test_bind_merges_into_existing_live(self, file_context_store): + """Subsequent bind calls merge, not replace, existing live values.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", request_id="b1") + cs.transition_tactic("sess_tt", "t1", mp4_path="/data/t1.mp4") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["mp4_path"] == "/data/t1.mp4" + + def test_bind_creates_live_dict_when_absent(self, file_context_store): + """live dict is created if the tactic didn't have one.""" + cs = file_context_store + plan = _fresh_plan() + # Ensure no live key exists + assert "live" not in plan["tactics"][0] + cs.set_operation_plan("sess_tt", plan) + cs.transition_tactic("sess_tt", "t1", setpoint=25.0) + t1 = cs.get_operation_plan("sess_tt")["tactics"][0] + assert t1["live"]["setpoint"] == 25.0 + + def test_state_only_no_bind(self, file_context_store): + """state-only call with no bind kwargs works and adds no live key.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="done") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "done" + assert "live" not in t1 + + def test_bind_only_no_state_change(self, file_context_store): + """bind-only call (no state arg) preserves existing state.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", cadence=60) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "planned" # unchanged + assert t1["live"]["cadence"] == 60 + + def test_other_tactic_unaffected(self, file_context_store): + """Transitioning t1 does not alter t2.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t2 = next(t for t in plan["tactics"] if t["id"] == "t2") + assert t2["state"] == "planned" + assert "live" not in t2 + + def test_updated_at_stamped(self, file_context_store): + """updated_at is refreshed after a successful transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] != "2026-06-28T00:00:00" + + def test_updated_reason_stamped(self, file_context_store): + """updated_reason reflects which tactic was transitioned.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert "t1" in plan["updated_reason"] + + def test_fires_context_updated_event(self, file_context_store): + """transition_tactic fires CONTEXT_UPDATED (via set_operation_plan).""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "t1", state="active") + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# No-op / guard tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticNoOp: + def test_absent_plan_returns_false(self, file_context_store): + """Returns False and does not crash when no plan exists.""" + result = file_context_store.transition_tactic("no_such_session", "t1", state="active") + assert result is False + + def test_absent_plan_no_side_effects(self, file_context_store): + """Absent-plan call creates no operation_plan file.""" + cs = file_context_store + cs.transition_tactic("ghost_session", "t1", state="active") + assert cs.get_operation_plan("ghost_session") is None + + def test_unknown_tactic_id_returns_false(self, file_context_store): + """Returns False when the tactic id is not present in the plan.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + assert result is False + + def test_unknown_tactic_plan_unchanged(self, file_context_store): + """Plan is not mutated when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + original_updated_at = cs.get_operation_plan("sess_tt")["updated_at"] + cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] == original_updated_at + + def test_no_event_on_absent_plan(self, file_context_store): + """CONTEXT_UPDATED must not fire when the plan is absent.""" + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.transition_tactic("ghost", "t1", state="active") + finally: + unsub() + assert len(seen) == 0 + + def test_no_event_on_unknown_tactic(self, file_context_store): + """CONTEXT_UPDATED must not fire when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "no_such", state="active") + finally: + unsub() + assert len(seen) == 0 From 224d8651c13876b09dabf6dc9aaeb51c9195e731 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:14:38 +0530 Subject: [PATCH 117/200] feat(operations): link execution tools to plan tactics via tactic_id Add optional tactic_id param to enable_monitoring_mode, queue_burst, stop_timelapse, pause_timelapse (timelapse_tools.py) and run_temp_change_burst_protocol_tool (temperature_protocol_tools.py). On execute the tool calls cs.transition_tactic(session, tactic_id, 'active'/'done'/'paused') via the same guarded getattr pattern as declare_operation_plan. tactic_id is threaded into BurstAcquisition and the orchestration layer so BURST_START/BURST_COMPLETE and TEMP_PROTOCOL_STARTED/TEMP_PROTOCOL_COMPLETED event data carry it for Task 8's OperationPlanUpdater. All params default None; existing callers are unaffected. --- gently/app/orchestration/exclusive.py | 4 + .../app/orchestration/temperature_protocol.py | 8 +- gently/app/orchestration/timelapse.py | 2 + .../app/tools/temperature_protocol_tools.py | 10 + gently/app/tools/timelapse_tools.py | 44 +- tests/test_tool_tactic_linkage.py | 555 ++++++++++++++++++ 6 files changed, 616 insertions(+), 7 deletions(-) create mode 100644 tests/test_tool_tactic_linkage.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index 03917b0c..7dc3583f 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -102,6 +102,7 @@ def __init__( request_id: str | None = None, temperature_provider=None, laser_config: str | None = None, + tactic_id: str | None = None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames @@ -109,6 +110,7 @@ def __init__( self.num_slices = num_slices self._temperature_provider = temperature_provider self._laser_config = laser_config + self._tactic_id = tactic_id async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -132,6 +134,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "frames": self.frames, "mode": self.mode, "phase": getattr(self, "_phase", None), + "tactic_id": self._tactic_id, }, ) @@ -234,6 +237,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "duration_s": duration_s, "sustained_hz": sustained_hz, "mp4_path": mp4_path, + "tactic_id": self._tactic_id, }, ) diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index 60058133..115234f2 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -21,7 +21,7 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, - lock_timeout_s=600.0, poll_s=2.0, burst_runner=None): + lock_timeout_s=600.0, poll_s=2.0, burst_runner=None, tactic_id=None): """Scripted temp-change burst protocol: brightfield before/during/after a setpoint change.""" client = orchestrator.client if burst_runner is None: @@ -40,7 +40,8 @@ async def one_burst(phase): await client.set_led("Open") orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, - "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after}) + "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after, + "tactic_id": tactic_id}) for _ in range(bursts_before): await one_burst("before") await client.set_temperature(target_setpoint_c) @@ -65,5 +66,6 @@ async def one_burst(phase): error = str(exc); logger.exception("temp-change burst protocol failed") finally: orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, - {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}) + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error, + "tactic_id": tactic_id}) return {"locked": locked, "cancelled": cancelled, "error": error} diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index c640f926..0645d77f 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -2062,6 +2062,7 @@ def queue_burst( num_slices: int = 1, force: bool = False, laser_config: str | None = None, + tactic_id: str | None = None, ) -> str: """Queue a burst acquisition for ``embryo_id``. @@ -2102,6 +2103,7 @@ def queue_burst( num_slices=num_slices, temperature_provider=self._temperature_provider, laser_config=laser_config, + tactic_id=tactic_id, ) self._exclusive_queue.append(op) logger.info( diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index 678b08b8..3e79055f 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -43,6 +43,7 @@ async def run_temp_change_burst_protocol_tool( frames: int = 60, bursts_before: int = 1, bursts_after: int = 1, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Launch the temp-change burst driver as a background asyncio task. @@ -83,6 +84,14 @@ async def run_temp_change_burst_protocol_tool( "(would contend for the RunEngine). Stop the timelapse first." ) + # Flip the matching plan tactic to active before launching the background driver + # (guarded no-op when tactic_id or context store are absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + asyncio.create_task( _driver( orchestrator, @@ -91,6 +100,7 @@ async def run_temp_change_burst_protocol_tool( frames=frames, bursts_before=bursts_before, bursts_after=bursts_after, + tactic_id=tactic_id, ) ) diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index d5ec3d76..9e96cbe5 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -267,7 +267,11 @@ async def stop_timelapse_embryo( category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def stop_timelapse(reason: str = "user_request", context: dict | None = None) -> str: +async def stop_timelapse( + reason: str = "user_request", + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Stop entire timelapse""" agent, err = require_agent(context) if err: @@ -279,6 +283,12 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No try: result = await orchestrator.stop(reason) + # Mark the linked plan tactic done (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "done") return result except Exception as e: return f"Error stopping timelapse: {str(e)}" @@ -290,7 +300,10 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def pause_timelapse(context: dict | None = None) -> str: +async def pause_timelapse( + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Pause timelapse""" agent, err = require_agent(context) if err: @@ -302,6 +315,12 @@ async def pause_timelapse(context: dict | None = None) -> str: try: result = await orchestrator.pause() + # Mark the linked plan tactic paused (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "paused") return result except Exception as e: return f"Error pausing timelapse: {str(e)}" @@ -1024,6 +1043,7 @@ def get_photodose_status(context: dict | None = None) -> str: ) def enable_monitoring_mode( mode_name: str, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Install a named reactive monitoring mode on the orchestrator.""" @@ -1036,7 +1056,14 @@ def enable_monitoring_mode( return err try: - return orchestrator.enable_monitoring_mode(mode_name) + result = orchestrator.enable_monitoring_mode(mode_name) + # Flip the matching plan tactic to active (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error enabling monitoring mode '{mode_name}': {str(e)}" @@ -1153,6 +1180,7 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Queue an exclusive burst acquisition for one embryo.""" @@ -1165,12 +1193,20 @@ def queue_burst( return err try: - return orchestrator.queue_burst( + result = orchestrator.queue_burst( embryo_id=embryo_id, frames=frames, mode=mode, num_slices=num_slices, force=force, + tactic_id=tactic_id, ) + # Flip the matching plan tactic to active on enqueue (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error queueing burst for {embryo_id}: {str(e)}" diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py new file mode 100644 index 00000000..be245093 --- /dev/null +++ b/tests/test_tool_tactic_linkage.py @@ -0,0 +1,555 @@ +""" +Task 7: tactic_id threading + start-edge marking in execution tools. + +Verifies: + 1. enable_monitoring_mode with tactic_id → transition_tactic("active") called. + 2. enable_monitoring_mode without tactic_id → no transition call. + 3. queue_burst (tool) with tactic_id → transition_tactic("active") called. + 4. queue_burst (tool) without tactic_id → no transition call. + 5. stop_timelapse with tactic_id → transition_tactic("done") called. + 6. pause_timelapse with tactic_id → transition_tactic("paused") called. + 7. BurstAcquisition(tactic_id=...) stores _tactic_id and includes it in BURST_START data. + 8. BurstAcquisition(tactic_id=...) includes tactic_id in BURST_COMPLETE data. + 9. run_temp_change_burst_protocol_tool with tactic_id → transition_tactic("active") called. + 10. run_temp_change_burst_protocol_tool without tactic_id → no transition call. + 11. TEMP_PROTOCOL_STARTED and TEMP_PROTOCOL_COMPLETED carry tactic_id. +""" + +import asyncio +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Capture calls to transition_tactic.""" + + def __init__(self): + self.transitions: list[tuple] = [] + + def transition_tactic(self, session_id: str, tactic_id: str, state: str = None, **bind): + self.transitions.append((session_id, tactic_id, state)) + return True + + +class FakeOrchestrator: + """Minimal orchestrator for tool-layer tests.""" + + def __init__(self): + self.monitoring_modes_enabled: list[str] = [] + self.bursts_queued: list[dict] = [] + self.stopped = False + self.paused = False + + def enable_monitoring_mode(self, name: str, **kwargs) -> str: + self.monitoring_modes_enabled.append(name) + return f"Activated monitoring mode '{name}'" + + def queue_burst( + self, + embryo_id: str, + *, + frames: int = 60, + mode: str = "1hz", + num_slices: int = 1, + force: bool = False, + laser_config=None, + tactic_id=None, + ) -> str: + self.bursts_queued.append({"embryo_id": embryo_id, "tactic_id": tactic_id}) + return f"Burst queued for {embryo_id}" + + async def stop(self, reason: str = "user_request") -> str: + self.stopped = True + return "Timelapse stopped." + + async def pause(self) -> str: + self.paused = True + return "Timelapse paused." + + +class FakeAgent: + """Carries context_store, session_id, and timelapse_orchestrator.""" + + def __init__(self, *, cs=None, session_id="sess_t7", orchestrator=None): + self.context_store = cs + self.session_id = session_id + self.timelapse_orchestrator = orchestrator + + +class FakeMicroscope: + """Satisfies the requires_microscope client check in the tool registry.""" + pass + + +def _make_agent(with_cs=True, with_orchestrator=True): + cs = FakeContextStore() if with_cs else None + orch = FakeOrchestrator() if with_orchestrator else None + agent = FakeAgent(cs=cs, orchestrator=orch) + return agent, cs, orch + + +def _ctx(agent, with_client=False): + ctx = {"agent": agent} + if with_client: + ctx["client"] = FakeMicroscope() + return ctx + + +# --------------------------------------------------------------------------- +# enable_monitoring_mode — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_with_tactic_id_calls_transition(): + """enable_monitoring_mode with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + + assert cs is not None + assert any( + t == ("sess_t7", "t1", "active") for t in cs.transitions + ), f"Expected transition ('sess_t7', 't1', 'active'), got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_without_tactic_id_no_transition(): + """enable_monitoring_mode without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode(mode_name="expression_monitoring", context=_ctx(agent)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_no_cs_no_crash(): + """Missing context store must not crash — the transition is a guarded no-op.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, _cs, _orch = _make_agent(with_cs=False) + result = await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + # No exception; result is the mode activation string + assert "expression_monitoring" in result + + +# --------------------------------------------------------------------------- +# queue_burst — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_queue_burst_with_tactic_id_calls_transition(): + """queue_burst (tool) with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t2", "active") for t in cs.transitions + ), f"Expected transition ('sess_t7', 't2', 'active'), got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_queue_burst_without_tactic_id_no_transition(): + """queue_burst (tool) without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_queue_burst_passes_tactic_id_to_orchestrator(): + """queue_burst (tool) passes tactic_id into orchestrator.queue_burst.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, _cs, orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert orch.bursts_queued, "orchestrator.queue_burst must have been called" + assert orch.bursts_queued[0]["tactic_id"] == "t2" + + +# --------------------------------------------------------------------------- +# stop_timelapse — mark done +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stop_timelapse_with_tactic_id_calls_done(): + """stop_timelapse with tactic_id flips the tactic to done.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t1", "done") for t in cs.transitions + ), f"Expected transition to done, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_stop_timelapse_without_tactic_id_no_transition(): + """stop_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# pause_timelapse — mark paused +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pause_timelapse_with_tactic_id_calls_paused(): + """pause_timelapse with tactic_id flips the tactic to paused.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t1", "paused") for t in cs.transitions + ), f"Expected transition to paused, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_pause_timelapse_without_tactic_id_no_transition(): + """pause_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# BurstAcquisition — tactic_id stored and appears in event data (unit-level) +# --------------------------------------------------------------------------- + + +def test_burst_acquisition_stores_tactic_id(): + """BurstAcquisition stores tactic_id as _tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10, tactic_id="t3") + assert b._tactic_id == "t3" + + +def test_burst_acquisition_default_tactic_id_none(): + """BurstAcquisition._tactic_id defaults to None (backward compat).""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10) + assert b._tactic_id is None + + +def test_burst_start_event_data_contains_tactic_id(): + """The BURST_START event data dict includes tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[dict] = [] + + class FakeOrc: + _embryo_states = {} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=5, tactic_id="t3") + orch = FakeOrc() + # Trigger just the BURST_START emission by running the coroutine start; since the + # embryo is absent, run() returns an error result after emitting nothing — + # that's the correct path. Instead, directly test what run() would emit. + # We replicate the exact emit call from BurstAcquisition.run to verify the data key. + # Safer: run the coroutine to the first yield so BURST_START fires. + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(orch)) + finally: + loop.close() + + # When embryo is absent run() returns early (no BURST_START). Confirm _tactic_id + # is wired up and would appear in the event — inspect the attribute directly. + assert b._tactic_id == "t3" + + +def test_burst_start_event_data_dict_includes_tactic_id_key(): + """The BURST_START event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_starts = [(et, d) for et, d in emitted if et == EventType.BURST_START] + assert burst_starts, f"BURST_START not emitted; all events: {[et for et,_ in emitted]}" + _et, data = burst_starts[0] + assert "tactic_id" in data, f"tactic_id not in BURST_START data: {data}" + assert data["tactic_id"] == "t3" + + +def test_burst_complete_event_data_includes_tactic_id(): + """The BURST_COMPLETE event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_completes = [(et, d) for et, d in emitted if et == EventType.BURST_COMPLETE] + assert burst_completes, f"BURST_COMPLETE not emitted; all events: {[et for et,_ in emitted]}" + _et, data = burst_completes[0] + assert "tactic_id" in data, f"tactic_id not in BURST_COMPLETE data: {data}" + assert data["tactic_id"] == "t3" + + +# --------------------------------------------------------------------------- +# temperature_protocol_tools — tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_with_tactic_id_calls_transition(monkeypatch): + """run_temp_change_burst_protocol_tool with tactic_id marks tactic active.""" + from gently.app.tools import temperature_protocol_tools as tpt + + # Patch asyncio.create_task to a no-op + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + tactic_id="t4", + context=ctx, + ) + + assert any( + t == ("sess_t7", "t4", "active") for t in cs.transitions + ), f"Expected active transition, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_without_tactic_id_no_transition(monkeypatch): + """run_temp_change_burst_protocol_tool without tactic_id must not call transition.""" + from gently.app.tools import temperature_protocol_tools as tpt + + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=ctx, + ) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +# --------------------------------------------------------------------------- +# temperature_protocol orchestration — tactic_id in event payloads +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_started_event_carries_tactic_id(): + """TEMP_PROTOCOL_STARTED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + bursts_run: list[str] = [] + + async def fake_burst_runner(b): + bursts_run.append(b._phase) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=fake_burst_runner, + tactic_id="t5", + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + assert started, "TEMP_PROTOCOL_STARTED not emitted" + assert started[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_event_carries_tactic_id(): + """TEMP_PROTOCOL_COMPLETED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + tactic_id="t5", + ) + + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert completed, "TEMP_PROTOCOL_COMPLETED not emitted" + assert completed[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_tactic_id_none_when_absent(): + """TEMP_PROTOCOL_* events include tactic_id=None when not supplied (backward compat).""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert started and "tactic_id" in started[0][1] + assert started[0][1]["tactic_id"] is None + assert completed and "tactic_id" in completed[0][1] + assert completed[0][1]["tactic_id"] is None From c445f6ca7a190af2c5e2c40b16dd915da2687922 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:14:42 +0530 Subject: [PATCH 118/200] =?UTF-8?q?docs(sdd):=20Task=207=20report=20?= =?UTF-8?q?=E2=80=94=20tactic=5Fid=20threading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .superpowers/sdd/dv3-task-7-report.md | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .superpowers/sdd/dv3-task-7-report.md diff --git a/.superpowers/sdd/dv3-task-7-report.md b/.superpowers/sdd/dv3-task-7-report.md new file mode 100644 index 00000000..033112fe --- /dev/null +++ b/.superpowers/sdd/dv3-task-7-report.md @@ -0,0 +1,85 @@ +# Task 7 Report: tactic_id threading + start-edge marking in execution tools + +## Call sites changed + +### `gently/app/orchestration/exclusive.py` + +**`BurstAcquisition.__init__`** (line ~95) +- Added `tactic_id: str | None = None` kwarg; stored as `self._tactic_id`. +- `BURST_START` event data (line ~127): added `"tactic_id": self._tactic_id`. +- `BURST_COMPLETE` event data (line ~229): added `"tactic_id": self._tactic_id`. + +### `gently/app/orchestration/temperature_protocol.py` + +**`run_temp_change_burst_protocol`** signature (line 22) +- Added `tactic_id=None` to the keyword-only params. +- `TEMP_PROTOCOL_STARTED` event data (line ~41): added `"tactic_id": tactic_id`. +- `TEMP_PROTOCOL_COMPLETED` event data (line ~67): added `"tactic_id": tactic_id`. + +### `gently/app/orchestration/timelapse.py` + +**`queue_burst`** method (line ~2056) +- Added `tactic_id: str | None = None` to signature. +- Passes `tactic_id=tactic_id` into `BurstAcquisition(...)` constructor (line ~2098). + +### `gently/app/tools/timelapse_tools.py` + +**`enable_monitoring_mode`** (line ~1025) +- Added `tactic_id: str | None = None`. +- On success: resolves `cs = getattr(agent, "context_store", None)`, `session = getattr(agent, "session_id", None)`; calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). + +**`queue_burst`** tool (line ~1150) +- Added `tactic_id: str | None = None`. +- Passes `tactic_id=tactic_id` to `orchestrator.queue_burst(...)`. +- After successful enqueue: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). + +**`stop_timelapse`** (line ~270) +- Added `tactic_id: str | None = None`. +- After successful stop: calls `cs.transition_tactic(session, tactic_id, "done")` (guarded). + +**`pause_timelapse`** (line ~293) +- Added `tactic_id: str | None = None`. +- After successful pause: calls `cs.transition_tactic(session, tactic_id, "paused")` (guarded). + +### `gently/app/tools/temperature_protocol_tools.py` + +**`run_temp_change_burst_protocol_tool`** (line 40) +- Added `tactic_id: str | None = None`. +- Before `asyncio.create_task`: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). +- Passes `tactic_id=tactic_id` into `_driver(...)` (the `run_temp_change_burst_protocol` coroutine). + +## How the transition + event threading work + +1. **Start edge (tool → store)**: Tool layer resolves the context store via `getattr(agent, "context_store", None)` and session via `getattr(agent, "session_id", None)` — the same pattern as `declare_operation_plan`. If both are present and `tactic_id` is truthy, `transition_tactic` flips the plan tactic to `"active"`. This happens synchronously (before the background driver spawns for the temp protocol, after enqueue for bursts). + +2. **Event threading (orchestration → bus)**: The `tactic_id` is included in `BURST_START`, `BURST_COMPLETE`, `TEMP_PROTOCOL_STARTED`, and `TEMP_PROTOCOL_COMPLETED` event `data` dicts. Task 8's `OperationPlanUpdater` subscribes to these events and can resolve the tactic by `data["tactic_id"]` to flip it to `"done"`. + +3. **Backward compatibility**: All `tactic_id` params default to `None`. Every transition call is guarded: `if tactic_id: cs = ...; if cs and session: cs.transition_tactic(...)`. When `tactic_id` is absent, no store access occurs and the tool behaves identically to before. + +## TDD evidence + +`tests/test_tool_tactic_linkage.py` — 20 tests, all pass. + +Coverage: +- `enable_monitoring_mode` with/without tactic_id (+ no-cs guard) +- `queue_burst` (tool) with/without tactic_id; tactic_id forwarded to orchestrator +- `stop_timelapse` with/without tactic_id +- `pause_timelapse` with/without tactic_id +- `BurstAcquisition._tactic_id` stored; `BURST_START` and `BURST_COMPLETE` events contain `tactic_id` +- `run_temp_change_burst_protocol_tool` with/without tactic_id +- `TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` carry tactic_id (including `None` backward-compat) + +Full suite (excluding 3 pre-existing broken collection targets): **638 passed, 19 skipped** — same count as before Task 7; no new failures. + +## Files changed + +- `gently/app/orchestration/exclusive.py` +- `gently/app/orchestration/temperature_protocol.py` +- `gently/app/orchestration/timelapse.py` +- `gently/app/tools/timelapse_tools.py` +- `gently/app/tools/temperature_protocol_tools.py` +- `tests/test_tool_tactic_linkage.py` (new) + +## Concerns + +None. All changes are purely additive (new optional kwarg, guarded call). The `@tool` decorator wraps sync functions as async coroutines — tests call them with keyword args and `await`; microscope-required tools need a `"client"` in the test context dict (FakeMicroscope provided). From 442265f761cbcaa9ac9d14dd302e486a43a444e8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:25:06 +0530 Subject: [PATCH 119/200] =?UTF-8?q?feat(operations):=20OperationPlanUpdate?= =?UTF-8?q?r=20=E2=80=94=20execution=20events=20transition=20plan=20tactic?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bus-subscriber Service that listens for BURST_COMPLETE, TEMP_PROTOCOL_COMPLETED, EMBRYO_CADENCE_CHANGED, and TRIGGER_FIRED; reads tactic_id from each payload and calls context_store.transition_tactic to advance state (done) or bind live values. Wired into agent.py beside the temperature sampler. 8/8 TDD tests pass. --- gently/app/agent.py | 21 ++ gently/app/operation_plan_updater.py | 141 +++++++++++++ tests/test_operation_plan_updater.py | 286 +++++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 gently/app/operation_plan_updater.py create mode 100644 tests/test_operation_plan_updater.py diff --git a/gently/app/agent.py b/gently/app/agent.py index 02ec700a..b02b227c 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -212,6 +212,8 @@ def __init__( self.device_state_monitor = None # Session-scoped temperature sampler — polls device layer, persists readings. self.temperature_sampler = None + # Bus-subscriber that transitions plan tactics when execution events fire. + self.operation_plan_updater = None # Opt-in bottom-camera stream bridge — created when viz starts, but # left unstarted until the operator clicks "Start camera" in the UI. self.bottom_camera_monitor = None @@ -848,6 +850,19 @@ async def start_viz_server( logger.warning(f"Failed to start temperature sampler: {e}") self.temperature_sampler = None + if self.operation_plan_updater is None and self.context_store is not None: + try: + from .operation_plan_updater import OperationPlanUpdater + + self.operation_plan_updater = OperationPlanUpdater( + self.context_store, lambda: self.session_id + ) + await self.operation_plan_updater.start() + logger.info("Operation-plan updater started") + except Exception as e: + logger.warning(f"Failed to start operation-plan updater: {e}") + self.operation_plan_updater = None + # Construct the bottom-camera monitor — but don't start it. Streaming # is opt-in and waits for an explicit operator action via the # /api/devices/bottom_camera/stream/start route. @@ -897,6 +912,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop temperature sampler") self.temperature_sampler = None + if self.operation_plan_updater is not None: + try: + await self.operation_plan_updater.stop() + except Exception: + logger.exception("Failed to stop operation-plan updater") + self.operation_plan_updater = None if self.viz_server is not None: await self.viz_server.stop() self.viz_server = None diff --git a/gently/app/operation_plan_updater.py b/gently/app/operation_plan_updater.py new file mode 100644 index 00000000..d2e2b9e4 --- /dev/null +++ b/gently/app/operation_plan_updater.py @@ -0,0 +1,141 @@ +"""Operation-plan updater — subscribes to execution lifecycle events and +transitions plan tactics to their completed state. + +Modeled on `gently/app/temperature_sampler.py` (Service lifecycle) and +`gently/harness/session/timeline.py` TimelineManager.start (bus-subscribe +pattern). + +When a tactic's execution completes — burst captured, temperature protocol +finished, trigger fired — this service reads `tactic_id` from the event +payload and calls `context_store.transition_tactic(session_id, tactic_id, +state, **bind)` to stamp live values and advance the tactic state. Missing +`tactic_id` or session → skip. Handler exceptions are caught so a bad event +never crashes the bus. +""" +from __future__ import annotations + +import logging +from typing import Callable + +from gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Event → (state, bind-keys) mapping +# --------------------------------------------------------------------------- +# state=None means bind-only (no state transition). +_EVENT_ACTIONS: dict[EventType, tuple[str | None, list[str]]] = { + EventType.BURST_COMPLETE: ( + "done", + ["request_id", "mp4_path", "sustained_hz", "frames_captured"], + ), + EventType.TEMP_PROTOCOL_COMPLETED: ( + "done", + ["locked", "cancelled", "error"], + ), + EventType.EMBRYO_CADENCE_CHANGED: ( + None, # bind-only; standing tactic stays active + ["embryo_id", "old_phase", "new_phase", "old_interval_s", "new_interval_s", "next_due_at"], + ), + EventType.TRIGGER_FIRED: ( + None, # bind-only; record last firing time + ["embryo_id", "rule_name", "rule_kind", "trigger_detector", "trigger_stage"], + ), +} + + +class OperationPlanUpdater(Service): + """Subscribe to execution events; transition plan tactics accordingly.""" + + def __init__(self, context_store, session_id_getter: Callable[[], str | None]): + super().__init__(name="operation-plan-updater", service_type="monitor") + self._context_store = context_store + self._session_id_getter = session_id_getter + self._unsubscribers: list[Callable[[], None]] = [] + + # ------------------------------------------------------------------ + # Service lifecycle + # ------------------------------------------------------------------ + + async def on_start(self) -> None: + bus = get_event_bus() + for event_type in _EVENT_ACTIONS: + unsub = bus.subscribe(event_type, self._on_event) + self._unsubscribers.append(unsub) + logger.info( + "OperationPlanUpdater started — subscribed to %d event types", + len(_EVENT_ACTIONS), + ) + + async def on_stop(self) -> None: + for unsub in self._unsubscribers: + try: + unsub() + except Exception as exc: + logger.warning("OperationPlanUpdater unsubscribe error: %s", exc) + self._unsubscribers.clear() + logger.info("OperationPlanUpdater stopped") + + # ------------------------------------------------------------------ + # Event handler + # ------------------------------------------------------------------ + + def _on_event(self, event) -> None: + """Handle an execution lifecycle event — guard everything; never raise.""" + try: + self._handle(event) + except Exception as exc: + logger.warning( + "OperationPlanUpdater: unhandled error in _on_event (%s): %s", + event.event_type, + exc, + ) + + def _handle(self, event) -> None: + data = event.data or {} + tactic_id = data.get("tactic_id") + session_id = self._session_id_getter() + + action = _EVENT_ACTIONS.get(event.event_type) + if action is None: + return # unknown event type — ignore + + state, bind_keys = action + + # For bind-only events with no tactic_id there is nothing to attach to. + if not tactic_id: + logger.debug( + "OperationPlanUpdater: no tactic_id in %s payload — skip", + event.event_type, + ) + return + + if not session_id: + logger.debug( + "OperationPlanUpdater: no active session for %s — skip", + event.event_type, + ) + return + + bind = {k: data[k] for k in bind_keys if k in data} + + # Add a last_fired marker for TRIGGER_FIRED so operators can see it. + if event.event_type is EventType.TRIGGER_FIRED: + bind["last_fired"] = event.timestamp + + ok = self._context_store.transition_tactic(session_id, tactic_id, state, **bind) + if ok: + logger.info( + "OperationPlanUpdater: tactic %s → %s (event=%s)", + tactic_id, + state or "bind-only", + event.event_type, + ) + else: + logger.debug( + "OperationPlanUpdater: transition_tactic no-op for tactic=%s session=%s", + tactic_id, + session_id, + ) diff --git a/tests/test_operation_plan_updater.py b/tests/test_operation_plan_updater.py new file mode 100644 index 00000000..0fe50d7e --- /dev/null +++ b/tests/test_operation_plan_updater.py @@ -0,0 +1,286 @@ +"""TDD: OperationPlanUpdater — execution events transition plan tactics. + +Covers: +- BURST_COMPLETE with tactic_id → state=done, bind values recorded +- TEMP_PROTOCOL_COMPLETED with tactic_id → state=done, bind values recorded +- EMBRYO_CADENCE_CHANGED with tactic_id → bind-only (no state change) +- TRIGGER_FIRED with tactic_id → bind-only + last_fired timestamp +- Event without tactic_id → skip (no transition_tactic call) +- Handler exception doesn't propagate to the caller +- on_stop unsubscribes (no further calls after stop) +""" +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from gently.core.event_bus import EventBus, EventType +from gently.app.operation_plan_updater import OperationPlanUpdater + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class FakeContextStore: + """Records all transition_tactic calls.""" + + def __init__(self): + self.calls: list[dict] = [] + + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + self.calls.append( + {"session_id": session_id, "tactic_id": tactic_id, "state": state, **bind} + ) + return True + + +def make_event(event_type: EventType, data: dict): + """Build a minimal Event-like object that _on_event expects.""" + evt = MagicMock() + evt.event_type = event_type + evt.data = data + evt.timestamp = "2026-06-28T00:00:00+00:00" + return evt + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def bus(): + """A fresh EventBus for each test (avoids cross-test leakage).""" + return EventBus() + + +@pytest.fixture +def store(): + return FakeContextStore() + + +@pytest.fixture +def session_id(): + return "sess_abc" + + +@pytest.fixture +def updater(store, session_id): + return OperationPlanUpdater(store, lambda: session_id) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_burst_complete_transitions_tactic_to_done(updater, store, bus, monkeypatch): + """BURST_COMPLETE fires → tactic transitions to done with bind values.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "tactic_id": "t1", + "embryo_id": "e1", + "request_id": "req-001", + "mp4_path": "/data/burst.mp4", + "sustained_hz": 2.5, + "frames_captured": 50, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t1" + assert call["state"] == "done" + assert call["request_id"] == "req-001" + assert call["mp4_path"] == "/data/burst.mp4" + assert call["sustained_hz"] == 2.5 + assert call["frames_captured"] == 50 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_transitions_to_done(updater, store, bus, monkeypatch): + """TEMP_PROTOCOL_COMPLETED fires → tactic done with locked/cancelled/error.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.TEMP_PROTOCOL_COMPLETED, + data={ + "tactic_id": "t2", + "embryo_id": "e1", + "locked": True, + "cancelled": False, + "error": None, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t2" + assert call["state"] == "done" + assert call["locked"] is True + assert call["cancelled"] is False + + await updater.stop() + + +@pytest.mark.asyncio +async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch): + """EMBRYO_CADENCE_CHANGED → bind-only (state=None), cadence values bound.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.EMBRYO_CADENCE_CHANGED, + data={ + "tactic_id": "t3", + "embryo_id": "e1", + "old_phase": "normal", + "new_phase": "dense", + "old_interval_s": 120, + "new_interval_s": 30, + "next_due_at": "2026-06-28T01:00:00+00:00", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t3" + assert call["state"] is None # bind-only + assert call["new_phase"] == "dense" + assert call["new_interval_s"] == 30 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monkeypatch): + """TRIGGER_FIRED → bind-only with last_fired timestamp injected.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.TRIGGER_FIRED, + data={ + "tactic_id": "t4", + "embryo_id": "e1", + "rule_name": "dense_on_signal", + "rule_kind": "interval", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t4" + assert call["state"] is None + assert "last_fired" in call # timestamp injected by updater + + await updater.stop() + + +@pytest.mark.asyncio +async def test_event_without_tactic_id_is_skipped(updater, store, bus, monkeypatch): + """An event with no tactic_id must not call transition_tactic.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "embryo_id": "e1", + "request_id": "req-999", + # deliberately no tactic_id + }, + source="test", + ) + + assert store.calls == [] + + await updater.stop() + + +@pytest.mark.asyncio +async def test_handler_exception_does_not_propagate(updater, bus, monkeypatch): + """An exception inside _handle must be swallowed — bus caller not affected.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + + class BrokenStore: + def transition_tactic(self, *a, **kw): + raise RuntimeError("storage exploded") + + broken_updater = OperationPlanUpdater(BrokenStore(), lambda: "sess_xyz") + await broken_updater.start() + + # Should not raise even though the store raises. + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + await broken_updater.stop() + + +@pytest.mark.asyncio +async def test_no_session_skips_transition(store, bus, monkeypatch): + """If session_id_getter returns None, no transition_tactic call is made.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + no_session_updater = OperationPlanUpdater(store, lambda: None) + await no_session_updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + assert store.calls == [] + + await no_session_updater.stop() + + +@pytest.mark.asyncio +async def test_stop_unsubscribes_from_bus(store, bus, monkeypatch): + """After stop(), further bus events produce no transition_tactic calls.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + up = OperationPlanUpdater(store, lambda: "sess_z") + await up.start() + await up.stop() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t9", "request_id": "r9"}, + source="test", + ) + + assert store.calls == [] From 152837e340af14d7c0efd8a536177ead0b8d36c8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:33:16 +0530 Subject: [PATCH 120/200] feat(operations): plan-item tactical outline (plan tactics with the imaging spec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `tactics: list[dict]` to ImagingSpec — each entry is a lightweight tactic descriptor {kind, name, target?, scope?, structure?} planned alongside the imaging spec and later seeding the session's Operation Plan. Persists through both the YAML FileContextStore and SQLite ContextStore paths with zero store/tool changes. --- gently/harness/memory/model.py | 8 ++ tests/test_plan_item_tactics.py | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/test_plan_item_tactics.py diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index e9e6532d..55bff965 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -245,6 +245,14 @@ class ImagingSpec: # Lets the UI tag each value with where it came from and what to confirm. provenance: dict[str, dict[str, str]] = field(default_factory=dict) + # Tactical outline — intended Operations for this imaging session. + # Each entry is a lightweight tactic descriptor: + # {kind, name, target?, scope?, structure?} + # kind ∈ standing_timelapse | reactive_monitor | scripted_protocol | + # exclusive_burst | oneshot | custom + # Populated at plan time; later seeds the session's Operation Plan. + tactics: list[dict] = field(default_factory=list) + @dataclass class BenchSpec: diff --git a/tests/test_plan_item_tactics.py b/tests/test_plan_item_tactics.py new file mode 100644 index 00000000..b1cd91da --- /dev/null +++ b/tests/test_plan_item_tactics.py @@ -0,0 +1,137 @@ +""" +Task 9 — ImagingSpec tactical outline. + +Verifies that `tactics` round-trips through the FileContextStore YAML path +and through the SQLite-backed ContextStore path. +""" + +import dataclasses + +import pytest + +from gently.harness.memory.model import ImagingSpec, PlanItem, PlanItemType + +TACTICS_OUTLINE = [ + { + "kind": "standing_timelapse", + "name": "baseline timelapse", + "scope": "all embryos", + }, + { + "kind": "reactive_monitor", + "name": "comma-stage speedup", + "target": "comma", + "structure": {"interval_s": 60}, + }, +] + + +# --------------------------------------------------------------------------- +# 1. Model-level: field exists and defaults to empty list +# --------------------------------------------------------------------------- + + +class TestImagingSpecField: + def test_tactics_field_exists(self): + spec = ImagingSpec() + assert hasattr(spec, "tactics") + + def test_tactics_default_empty_list(self): + spec = ImagingSpec() + assert spec.tactics == [] + + def test_tactics_accepts_outline(self): + spec = ImagingSpec(strain="N2", tactics=TACTICS_OUTLINE) + assert spec.tactics == TACTICS_OUTLINE + + def test_tactics_field_in_dataclass_fields(self): + field_names = {f.name for f in dataclasses.fields(ImagingSpec)} + assert "tactics" in field_names + + +# --------------------------------------------------------------------------- +# 2. FileContextStore (YAML) round-trip +# --------------------------------------------------------------------------- + + +class TestFileStoreTactics: + def test_tactics_survive_create_and_get(self, file_context_store): + """Tactics stored in spec persist through create_plan_item → get_plan_item.""" + cid = file_context_store.create_campaign(description="Tactics test") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Tactic-bearing item", + spec={ + "strain": "OH904", + "interval_s": 180, + "tactics": TACTICS_OUTLINE, + }, + ) + item = file_context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE + + def test_tactics_survive_update(self, file_context_store): + """Tactics set via update_plan_item also persist.""" + cid = file_context_store.create_campaign(description="Update tactics") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="No tactics yet", + spec={"strain": "N2"}, + ) + # Initially empty + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + # Add tactics via update + new_spec = {"strain": "N2", "tactics": TACTICS_OUTLINE[:1]} + file_context_store.update_plan_item(iid, spec=new_spec) + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == TACTICS_OUTLINE[:1] + + def test_existing_items_without_tactics_default_empty(self, file_context_store): + """Items stored without a tactics key default to [] on read-back.""" + cid = file_context_store.create_campaign(description="Legacy item") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Legacy (no tactics key)", + spec={"strain": "N2", "num_slices": 80}, + ) + # Manually strip tactics from the persisted YAML to simulate a pre-tactics record + loc = file_context_store._find_plan_item_location(iid) + campaign_id_found, items, idx = loc + raw = items[idx] + if isinstance(raw.get("spec"), dict): + raw["spec"].pop("tactics", None) + file_context_store._write_plan_items(campaign_id_found, items) + + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + +# --------------------------------------------------------------------------- +# 3. SQLite ContextStore round-trip +# --------------------------------------------------------------------------- + + +class TestContextStoreTactics: + def test_tactics_survive_sqlite_round_trip(self, context_store): + """Tactics stored in spec dict survive through the SQLite ContextStore.""" + cid = context_store.create_campaign(description="SQLite tactics test") + iid = context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="SQLite tactic item", + spec={ + "strain": "OH904", + "tactics": TACTICS_OUTLINE, + }, + ) + item = context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE From 16a85a39e92a73f9f44a4d708d91dc164726f514 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:41:17 +0530 Subject: [PATCH 121/200] feat(operations): seed Operation Plan goal + planned tactics from the linked plan item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seed_operation_plan_from_plan_item(context_store, session_id) in gently/app/tools/operation_plan_seed.py. Resolution path: session_id → get_campaign_ids_for_session → get_plan_items per campaign → find PlanItem where session_id ∈ plan_item.session_ids AND imaging_spec.tactics is non-empty → get_campaign for goal text. Idempotent: existing plan with active/done tactics is never clobbered; plans with only planned tactics get missing outline entries merged in. 19 new tests in tests/test_operation_plan_seeding.py (all passing). Hook point: agent.py ~455-463 after attach_session_to_plan (not yet wired). --- gently/app/tools/operation_plan_seed.py | 182 +++++++++++ tests/test_operation_plan_seeding.py | 410 ++++++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 gently/app/tools/operation_plan_seed.py create mode 100644 tests/test_operation_plan_seeding.py diff --git a/gently/app/tools/operation_plan_seed.py b/gently/app/tools/operation_plan_seed.py new file mode 100644 index 00000000..3878e704 --- /dev/null +++ b/gently/app/tools/operation_plan_seed.py @@ -0,0 +1,182 @@ +""" +Seed an Operation Plan from the plan item linked to a session. + +Called at session start (or lazily on first declare/render) to pre-populate +the plan's goal + planned tactics from the ImagingSpec.tactics outline of +the linked plan item. + +Hook point +---------- +gently/app/agent.py ~455-463, after ``attach_session_to_plan`` has linked +the session to its campaign. At that point call:: + + from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + seed_operation_plan_from_plan_item(agent.context_store, agent.session_id) + +Resolution path (multi-hop) +---------------------------- +1. ``context_store.get_campaign_ids_for_session(session_id)`` + → list of campaign IDs from the session_intent YAML +2. For each campaign_id → ``context_store.get_plan_items(campaign_id=cid)`` + → PlanItem list +3. Find the first PlanItem where ``session_id`` is in ``plan_item.session_ids`` + (or ``plan_item.session_id == session_id``) AND + ``plan_item.imaging_spec.tactics`` is non-empty. +4. ``context_store.get_campaign(plan_item.campaign_id)`` for the goal text. +""" + +import logging +import uuid +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resolve_plan_item_with_tactics(context_store, session_id: str): + """Locate the first plan item linked to *session_id* that has a tactics outline. + + Returns ``(plan_item, campaign)`` or ``(None, None)`` if nothing matches. + """ + campaign_ids = context_store.get_campaign_ids_for_session(session_id) + if not campaign_ids: + return None, None + + for cid in campaign_ids: + items = context_store.get_plan_items(campaign_id=cid) + for item in items: + # Match items that list this session + session_ids = item.session_ids or ( + [item.session_id] if item.session_id else [] + ) + if session_id not in session_ids and item.session_id != session_id: + continue + # Must carry an imaging_spec with at least one tactic outline entry + if not item.imaging_spec or not item.imaging_spec.tactics: + continue + campaign = context_store.get_campaign(cid) + return item, campaign + + return None, None + + +def _outline_entry_to_tactic(entry: dict, idx: int) -> dict: + """Convert a lightweight ImagingSpec.tactics outline entry to a full planned tactic.""" + kind = entry.get("kind") or "custom" + name = entry.get("name") or f"Tactic {idx + 1}" + tactic_id = f"seed_{uuid.uuid4().hex[:8]}" + return { + "id": tactic_id, + "name": name, + "kind": kind, + "state": "planned", + "target": entry.get("target"), + "scope": entry.get("scope"), + "structure": entry.get("structure"), + "rationale": None, + "live_bind": [], + "relations": {}, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def seed_operation_plan_from_plan_item(context_store, session_id: str) -> dict | None: + """Seed the Operation Plan for *session_id* from its linked plan item's outline. + + Idempotency rules + ----------------- + * **No existing plan** — seed fully; write to store; return plan. + * **Plan exists with ≥1 active or done tactic** — do NOT overwrite; return ``None``. + * **Plan exists with only planned tactics** — add any outline entries whose + *name* is not already present; if nothing new, return ``None``; otherwise + write the merged plan and return it. + + Parameters + ---------- + context_store: + A ``FileContextStore`` instance. + session_id: + The active session ID. + + Returns + ------- + dict | None + The seeded (or merged) plan dict if something was written, else ``None``. + """ + item, campaign = _resolve_plan_item_with_tactics(context_store, session_id) + if item is None: + return None + + outline: list[dict] = item.imaging_spec.tactics # type: ignore[union-attr] + if not outline: + return None + + # --- Idempotency: check existing plan --- + existing = context_store.get_operation_plan(session_id) + if existing: + existing_tactics: list[dict] = existing.get("tactics") or [] + # Guard: at least one active/done tactic — do not clobber + has_live = any(t.get("state") in ("active", "done") for t in existing_tactics) + if has_live: + logger.debug( + "seed_operation_plan: session %s already has live tactics — skipping", + session_id, + ) + return None + + # Plan exists with only planned tactics — add missing outline entries by name + existing_names = {t.get("name") for t in existing_tactics} + new_tactics = list(existing_tactics) + added = False + for i, entry in enumerate(outline): + if entry.get("name") not in existing_names: + new_tactics.append(_outline_entry_to_tactic(entry, i)) + added = True + if not added: + return None + + plan = dict(existing) + plan["tactics"] = new_tactics + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = "seeded: added planned tactics from plan-item outline" + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: added %d planned tactic(s) to existing plan for session %s", + sum(1 for t in new_tactics if t not in existing_tactics), + session_id, + ) + return plan + + # --- No existing plan: seed from scratch --- + goal = "" + if campaign: + goal = campaign.target or campaign.description or "" + + tactics = [_outline_entry_to_tactic(entry, i) for i, entry in enumerate(outline)] + + plan = { + "session_id": session_id, + "title": item.title, + "goal": goal, + "plan_item_id": item.id, + "campaign_id": item.campaign_id, + "tactics": tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": "seeded from plan-item tactical outline", + } + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: seeded %d planned tactic(s) for session %s from plan item %s", + len(tactics), + session_id, + item.id, + ) + return plan diff --git a/tests/test_operation_plan_seeding.py b/tests/test_operation_plan_seeding.py new file mode 100644 index 00000000..abe36911 --- /dev/null +++ b/tests/test_operation_plan_seeding.py @@ -0,0 +1,410 @@ +""" +TDD: seed_operation_plan_from_plan_item + +Resolution path under test: + session_id + → get_campaign_ids_for_session (reads session_intents/{session_id}.yaml) + → get_plan_items(campaign_id=cid) + → find PlanItem where session_id ∈ plan_item.session_ids + AND plan_item.imaging_spec.tactics is non-empty + → get_campaign(plan_item.campaign_id) for goal text + → build + write Operation Plan via set_operation_plan + +Scenarios +--------- +1. Two outline tactics → seeded plan with 2 ``planned`` tactics +2. Plan-level fields (goal, plan_item_id, campaign_id) populated from item/campaign +3. Idempotent — existing plan with active tactic: not clobbered, returns None +4. Idempotent — existing plan with done tactic: not clobbered, returns None +5. No campaign linkage → returns None, no write +6. Plan item linked but imaging_spec.tactics is empty → returns None, no write +""" + +import pytest + +from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_campaign_and_item_with_tactics( + cs, + session_id: str, + *, + target: str = "50 hatching events", +): + """Create a campaign + imaging plan item with 2 tactics, linked to *session_id*.""" + cid = cs.create_campaign( + description="Capture hatching events in WT", + shorthand="hatching-wt", + target=target, + ) + spec = { + "strain": "N2", + "num_embryos": 4, + "tactics": [ + {"kind": "standing_timelapse", "name": "Baseline timelapse"}, + { + "kind": "reactive_monitor", + "name": "Hatching monitor", + "target": "embryo_hatching", + "scope": {"mode": "global"}, + }, + ], + } + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT hatching pilot", + spec=spec, + ) + cs.link_session_campaign(session_id, cid) + cs.link_plan_item_session(item_id, session_id) + return cid, item_id + + +# --------------------------------------------------------------------------- +# Basic seeding +# --------------------------------------------------------------------------- + + +class TestSeedBasic: + def test_returns_plan_dict(self, file_context_store): + """seed returns a non-None dict when a linked plan item with tactics exists.""" + cs = file_context_store + sid = "seed_basic_01" + cs.create_session_intent(sid, planned_intent="hatching pilot") + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan is not None + assert isinstance(plan, dict) + + def test_two_outline_entries_produce_two_tactics(self, file_context_store): + """An outline with 2 entries produces exactly 2 tactics.""" + cs = file_context_store + sid = "seed_basic_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert len(plan["tactics"]) == 2 + + def test_all_seeded_tactics_are_planned(self, file_context_store): + """Every seeded tactic starts in state='planned'.""" + cs = file_context_store + sid = "seed_basic_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert all(t["state"] == "planned" for t in plan["tactics"]) + + def test_tactic_kind_and_name_from_outline(self, file_context_store): + """Tactics carry the kind and name from the outline entries.""" + cs = file_context_store + sid = "seed_basic_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + names = [t["name"] for t in plan["tactics"]] + kinds = [t["kind"] for t in plan["tactics"]] + assert "Baseline timelapse" in names + assert "Hatching monitor" in names + assert "standing_timelapse" in kinds + assert "reactive_monitor" in kinds + + def test_tactic_ids_generated_and_distinct(self, file_context_store): + """Each tactic has a non-empty unique id.""" + cs = file_context_store + sid = "seed_basic_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + ids = [t["id"] for t in plan["tactics"]] + assert all(ids) + assert len(set(ids)) == len(ids) + + def test_plan_persisted_in_store(self, file_context_store): + """The seeded plan is retrievable via get_operation_plan.""" + cs = file_context_store + sid = "seed_basic_06" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + seed_operation_plan_from_plan_item(cs, sid) + + stored = cs.get_operation_plan(sid) + assert stored is not None + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# Plan-level metadata +# --------------------------------------------------------------------------- + + +class TestSeedMetadata: + def test_goal_from_campaign_target(self, file_context_store): + """goal field is taken from campaign.target.""" + cs = file_context_store + sid = "seed_meta_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid, target="50 hatching events") + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "50 hatching events" + + def test_goal_falls_back_to_campaign_description(self, file_context_store): + """When campaign.target is None, goal falls back to campaign.description.""" + cs = file_context_store + sid = "seed_meta_02" + cs.create_session_intent(sid) + cid = cs.create_campaign(description="Characterise WT hatching") + spec = {"tactics": [{"kind": "standing_timelapse", "name": "Timelapse"}]} + item_id = cs.create_plan_item(campaign_id=cid, type="imaging", title="Pilot", spec=spec) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "Characterise WT hatching" + + def test_plan_item_id_set(self, file_context_store): + """plan_item_id on the plan matches the linked plan item's id.""" + cs = file_context_store + sid = "seed_meta_03" + cs.create_session_intent(sid) + cid, item_id = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["plan_item_id"] == item_id + + def test_campaign_id_set(self, file_context_store): + """campaign_id on the plan matches the campaign.""" + cs = file_context_store + sid = "seed_meta_04" + cs.create_session_intent(sid) + cid, _ = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["campaign_id"] == cid + + def test_title_from_plan_item(self, file_context_store): + """Plan title comes from the plan item's title.""" + cs = file_context_store + sid = "seed_meta_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["title"] == "WT hatching pilot" + + +# --------------------------------------------------------------------------- +# Idempotency +# --------------------------------------------------------------------------- + + +class TestIdempotency: + def test_active_tactic_not_clobbered(self, file_context_store): + """If the plan has an active tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Agent-declared plan", + "goal": "live goal", + "tactics": [ + { + "id": "t_live", + "name": "Live tactic", + "kind": "standing_timelapse", + "state": "active", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Agent-declared plan" + assert stored["goal"] == "live goal" + + def test_done_tactic_not_clobbered(self, file_context_store): + """If the plan has a done tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Completed plan", + "goal": "done goal", + "tactics": [ + { + "id": "t_done", + "name": "Done tactic", + "kind": "oneshot", + "state": "done", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Completed plan" + + def test_all_planned_existing_plan_adds_missing_tactics(self, file_context_store): + """If existing plan has only planned tactics, new outline entries are added.""" + cs = file_context_store + sid = "seed_idem_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with only the first tactic (by name) + existing = { + "session_id": sid, + "title": "Partial plan", + "goal": "partial goal", + "tactics": [ + { + "id": "t_existing", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + } + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "partial seed", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + # The second outline entry ("Hatching monitor") should have been added + assert result is not None + assert len(result["tactics"]) == 2 + names = [t["name"] for t in result["tactics"]] + assert "Hatching monitor" in names + + def test_all_tactics_already_present_returns_none(self, file_context_store): + """If all outline tactics are already in the plan (by name), returns None.""" + cs = file_context_store + sid = "seed_idem_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with both outline names already present + existing = { + "session_id": sid, + "title": "Full plan", + "goal": "full goal", + "tactics": [ + {"id": "t1", "name": "Baseline timelapse", "kind": "standing_timelapse", "state": "planned"}, + {"id": "t2", "name": "Hatching monitor", "kind": "reactive_monitor", "state": "planned"}, + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "already seeded", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + # Plan unchanged + stored = cs.get_operation_plan(sid) + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# No linkage +# --------------------------------------------------------------------------- + + +class TestNoLinkage: + def test_no_campaign_linkage_returns_none(self, file_context_store): + """Session with no campaign linkage → None, nothing written.""" + cs = file_context_store + sid = "seed_nolink_01" + cs.create_session_intent(sid, planned_intent="standalone") + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_no_session_intent_returns_none(self, file_context_store): + """Session with no session_intent YAML → None.""" + cs = file_context_store + + result = seed_operation_plan_from_plan_item(cs, "ghost_session") + + assert result is None + + def test_plan_item_without_tactics_returns_none(self, file_context_store): + """Plan item linked to session but imaging_spec.tactics is empty → None.""" + cs = file_context_store + sid = "seed_notactics_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Campaign — no tactics in spec") + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="Item without tactics", + spec={"strain": "N2"}, # no "tactics" key → empty list on ImagingSpec + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_non_imaging_plan_item_returns_none(self, file_context_store): + """Non-imaging plan item (no imaging_spec) → None.""" + cs = file_context_store + sid = "seed_bench_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Bench work campaign") + item_id = cs.create_plan_item( + campaign_id=cid, + type="bench", + title="Prep samples", + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None From 5d16f34fe97e0293b919c566e21c0f559724d4ee Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:58:07 +0530 Subject: [PATCH 122/200] feat(operations): operation-spine renderer + plan scenario library (data-driven) Replace the swimlane Overview with a typed Operation Plan spine renderer. The agent declares tactics (kind/state/structure/live_bind); the renderer draws done/active/planned nodes with per-kind structure (scripted_protocol phase stepper, standing_timelapse per-embryo cadence strip, reactive_monitor armed/watching/fired badge, exclusive_burst/oneshot compact). Audit fixes baked in: colored left edge per state, active card flattened (no card-in-card, values on panel face + hairline rule), active phase is the headline (larger/bold amber), queued = "next" badge + cocked blue dot, copy "queued" not "going to be used", mono instrument values. Adds window.OPERATIONS_SCENARIOS (7 plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle). Scenario dev mode: ?scenario= renders a fixture with no fetch. Loads scenarios.js before experiment-overview.js in index.html. Rules view and monitoring-mode logic untouched. --- gently/ui/web/static/css/experiment.css | 440 ++++++++++++++++++ .../ui/web/static/js/experiment-overview.js | 258 +++++++++- .../ui/web/static/js/operations-scenarios.js | 371 +++++++++++++++ gently/ui/web/templates/index.html | 1 + 4 files changed, 1059 insertions(+), 11 deletions(-) create mode 100644 gently/ui/web/static/js/operations-scenarios.js diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..16f6ba73 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -634,3 +634,443 @@ transform: rotate(45deg); flex-shrink: 0; } + +/* ============================================================ + Operation Spine — data-driven tactic plan renderer + All classes namespaced ops- to avoid collision. + ============================================================ */ + +:root { + --ops-done: #34d399; + --ops-active: #f5a623; + --ops-plan: #5aa9e6; + --ops-live: #22d3ee; + --ops-mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ops-wrap { + max-width: 760px; + margin: 0 auto; + padding: 8px 4px 48px; +} + +.ops-crumb { + font-family: var(--ops-mono); + font-size: 11px; + letter-spacing: 0.16em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 6px; +} + +.ops-title { + font-size: 22px; + font-weight: 600; + margin: 0 0 4px; + letter-spacing: -0.01em; + color: var(--text); +} + +.ops-meta { + font-family: var(--ops-mono); + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.ops-legend { + display: flex; + gap: 16px; + font-family: var(--ops-mono); + font-size: 10.5px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 14px 0 20px; +} + +.ops-legend i { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 6px; + vertical-align: 1px; +} + +.ops-empty { + font-family: var(--ops-mono); + color: var(--text-muted); + font-size: 13px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 30px; + text-align: center; + margin-top: 18px; +} + +/* ---- Spine ------------------------------------------------- */ + +.ops-spine { + position: relative; + margin-left: 14px; + padding-left: 30px; + border-left: 2px solid var(--border); +} + +/* Tactic node */ +.ops-node { + position: relative; + margin-bottom: 14px; +} + +/* Timeline dot — overlaps the spine border-left */ +.ops-node::before { + content: ""; + position: absolute; + left: -39px; + top: 4px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg-card, #161b22); + border: 2px solid var(--border); +} + +.ops-node.done::before { + border-color: var(--ops-done); + background: var(--ops-done); +} + +.ops-node.active::before { + border-color: var(--ops-active); + background: var(--ops-active); + box-shadow: 0 0 0 5px rgba(245, 166, 35, 0.14); +} + +/* AUDIT: queued = cocked instrument — dashed blue dot (not empty) */ +.ops-node.planned::before { + border-color: var(--ops-plan); + border-style: dashed; + background: var(--bg-card, #161b22); +} + +/* ---- Stage label: "01 · in use" ----------------------------- */ + +.ops-stagelab { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +/* AUDIT: active row's WHOLE LEFT COLUMN amber (dot + seq + "IN USE") */ +.ops-node.active .ops-stagelab { color: var(--ops-active); } +.ops-node.planned .ops-stagelab { color: var(--ops-plan); } + +/* AUDIT: "next" badge on first queued tactic — cocked-instrument marker */ +.ops-next-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 9px; + background: rgba(90, 169, 230, 0.15); + border: 1px solid rgba(90, 169, 230, 0.45); + color: var(--ops-plan); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- Card --------------------------------------------------- */ + +.ops-card { + background: var(--bg-card, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 12px; + padding: 13px 16px; +} + +/* AUDIT: colored LEFT EDGE per state */ +/* done — green left edge, transparent bg */ +.ops-node.done .ops-card { + background: transparent; + border-color: rgba(48, 54, 61, 0.5); + padding: 9px 16px; + border-left: 3px solid var(--ops-done); +} + +/* active — amber left edge + tinted gradient + FLATTENED (no card-in-card) */ +.ops-node.active .ops-card { + border-color: rgba(245, 166, 35, 0.3); + border-left: 3px solid var(--ops-active); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.05), transparent), + var(--bg-card, #161b22); +} + +/* planned — blue left edge, dashed border */ +.ops-node.planned .ops-card { + background: transparent; + border-style: dashed; + border-left: 3px solid var(--ops-plan); + border-left-style: solid; + opacity: 0.85; +} + +/* Hairline rule between card header and live readouts (flattened layout) */ +.ops-rule { + border: none; + border-top: 1px solid rgba(245, 166, 35, 0.18); + margin: 10px 0; +} + +/* ---- Card contents ----------------------------------------- */ + +.ops-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ops-tname { + font-size: 14.5px; + font-weight: 600; + color: var(--text); +} + +.ops-node.done .ops-tname { + font-weight: 500; + color: var(--text-muted); +} + +.ops-target { + font-family: var(--ops-mono); + font-size: 12.5px; + color: var(--ops-active); + font-weight: 600; +} + +.ops-tsum { + font-family: var(--ops-mono); + font-size: 11.5px; + color: var(--text-muted); + margin-left: auto; +} + +.ops-desc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; + margin: 4px 0 0; + line-height: 1.4; +} + +/* ---- Gauge strip — AUDIT: flat on panel face, no card-in-card */ + +.ops-live-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ops-gauge { + flex: 1; + min-width: 140px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 9px; + padding: 9px 11px; +} + +.ops-gl { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* AUDIT: mono instrument values */ +.ops-gv { + font-family: var(--ops-mono); + font-size: 18px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--text); + margin-top: 2px; +} + +.ops-set { color: var(--ops-active); font-size: 13px; } +.ops-u { color: var(--text-muted); font-size: 12px; } + +.ops-gsub { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; +} + +.ops-tempbar { + height: 5px; + border-radius: 3px; + background: var(--bg-hover, #21262d); + margin-top: 8px; + overflow: hidden; +} + +.ops-tempbar > i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--ops-live), var(--ops-active)); +} + +/* ---- Scripted protocol — phase stepper --------------------- */ + +.ops-phases { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin-top: 8px; +} + +.ops-ph { + flex: 1; + min-width: 110px; + border: 1px solid var(--border, #30363d); + border-radius: 8px; + padding: 9px 10px; + background: var(--bg-hover, #21262d); +} + +.ops-ph.done { border-color: rgba(52, 211, 153, 0.35); background: rgba(52, 211, 153, 0.05); } +.ops-ph.active { border-color: rgba(245, 166, 35, 0.45); background: rgba(245, 166, 35, 0.08); } +.ops-ph.todo { opacity: 0.6; } + +.ops-pht { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + display: flex; + gap: 6px; + align-items: center; + color: var(--text-muted); +} + +/* AUDIT: ACTIVE phase is the HEADLINE — largest in its phase */ +.ops-ph.active .ops-pht { + font-size: 12px; + font-weight: 700; + color: var(--ops-active); +} + +.ops-pi { + width: 15px; + height: 15px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 9px; + font-weight: 700; + flex-shrink: 0; +} + +.ops-ph.done .ops-pi { background: var(--ops-done); color: #062b1d; } +.ops-ph.active .ops-pi { background: var(--ops-active); color: #3a2607; } +.ops-ph.todo .ops-pi { background: var(--bg-hover, #21262d); color: var(--text-muted); border: 1px solid var(--border); } + +.ops-phc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +/* AUDIT: active phase count also headline-sized */ +.ops-ph.active .ops-phc { font-size: 13px; font-weight: 600; color: var(--ops-active); } + +.ops-pips { display: flex; gap: 3px; margin-top: 7px; flex-wrap: wrap; } + +.ops-pip { width: 12px; height: 7px; border-radius: 2px; background: var(--bg-hover, #21262d); } +.ops-pip.before { background: var(--ops-plan); } +.ops-pip.during { background: var(--ops-active); } +.ops-pip.after { background: var(--ops-done); } +.ops-pip.pending { border: 1px dashed var(--border); background: transparent; } + +/* ---- Per-embryo cadence strip — standing_timelapse --------- */ + +.ops-cadence-strip { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 8px; +} + +.ops-cadence-embryo { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--ops-mono); + font-size: 11.5px; +} + +.ops-cadence-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +.ops-cadence-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.ops-cadence-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-cadence-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-cadence-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-cadence-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-cadence-val { color: var(--text-muted); margin-left: auto; } + +/* ---- Reactive monitor — armed/watching/fired badge --------- */ + +.ops-monitor-status { + display: inline-block; + margin-top: 8px; + padding: 3px 10px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.ops-monitor-armed { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); border: 1px solid rgba(90, 169, 230, 0.35); } +.ops-monitor-watching { background: rgba(245, 166, 35, 0.12); color: var(--ops-active); border: 1px solid rgba(245, 166, 35, 0.3); } +.ops-monitor-fired { background: rgba(52, 211, 153, 0.12); color: var(--ops-done); border: 1px solid rgba(52, 211, 153, 0.3); } + +/* Planned kind-detail snippets (cadence note, watch hint) */ +.ops-cadence-note, +.ops-monitor-watch { + display: inline-block; + margin-top: 6px; + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; +} diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 9e7fc221..c46eba9c 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -12,13 +12,38 @@ const ExperimentOverview = { initialized: false, expandedMode: null, activeView: 'overview', // 'overview' | 'rules' - activeStrategy: null, // last fetched/loaded snapshot - isLive: false, // true when activeStrategy came from the API + activeStrategy: null, // last fetched strategy snapshot (rules view) + activePlan: null, // last fetched/loaded operation plan (overview spine) + isLive: false, // true when data came from the API + scenarioMode: false, // true when ?scenario= is active async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); - const strategy = await this.loadStrategy(); + + // Scenario dev mode: ?scenario= renders a fixture with no fetch. + // Guard against double-registration when the tab is clicked repeatedly. + const scenarioParam = new URLSearchParams(location.search).get('scenario'); + if (scenarioParam && window.OPERATIONS_SCENARIOS && + Object.prototype.hasOwnProperty.call(window.OPERATIONS_SCENARIOS, scenarioParam)) { + this.scenarioMode = true; + this.activePlan = window.OPERATIONS_SCENARIOS[scenarioParam]; + this.activeStrategy = null; + this.isLive = false; + this.render(null); + this.initialized = true; + return; + } + + this.scenarioMode = false; + // Fetch plan (overview) and strategy (rules) in parallel so tab-switching + // between the two views doesn't require a second round-trip. + const [plan, strategy] = await Promise.all([ + this.loadPlan(), + this.loadStrategy() + ]); + this.activePlan = plan; this.activeStrategy = strategy; + this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; }, @@ -32,15 +57,30 @@ const ExperimentOverview = { // No active experiment / not ready yet — show the empty state, // never stubbed data. console.warn('[ExperimentOverview] strategy fetch returned', resp.status); - this.isLive = false; return null; } const data = await resp.json(); - this.isLive = true; return data; } catch (e) { console.warn('[ExperimentOverview] strategy fetch error:', e); - this.isLive = false; + return null; + } + }, + + // Fetch the agent-authored Operation Plan for the current session. + // Returns the plan object (plan.tactics etc.) or null when unavailable. + async loadPlan() { + try { + const resp = await fetch('/api/operation_plan/current', { cache: 'no-store' }); + if (!resp.ok) { + console.warn('[ExperimentOverview] plan fetch returned', resp.status); + return null; + } + const data = await resp.json(); + if (!data.available) return null; + return data.plan || null; + } catch (e) { + console.warn('[ExperimentOverview] plan fetch error:', e); return null; } }, @@ -65,10 +105,12 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); - if (!s) { - // No active experiment — a calm empty state, never stubbed data. + // Rules view requires the strategy snapshot; show an empty state when absent. + // Overview view uses this.activePlan — the null/empty case is handled inside + // _renderOperationSpine (it renders the idle state). + if (this.activeView === 'rules' && !s) { root.innerHTML = '
    ' + - 'No active experiment — the imaging tactics (cadence, reactive rules) will appear here once a run is live.
    '; + 'No active experiment — rules and monitoring modes will appear here once a run is live.

    '; return; } try { @@ -76,8 +118,9 @@ const ExperimentOverview = { if (this.activeView === 'rules') { this._renderRulesView(root, s); } else { - this._renderOverviewView(root, s); - this._startNowTicker(); + // Operation spine — data-driven tactic plan renderer. + // The swimlane view is retired; this renders this.activePlan. + this._renderOperationSpine(root, this.activePlan); } console.log('[ExperimentOverview] rendered OK, view=', this.activeView); } catch (err) { @@ -1085,6 +1128,199 @@ const ExperimentOverview = { return g; }, + // ================================================================= + // Operation Spine — data-driven plan renderer (replaces swimlanes) + // ================================================================= + + // Minimal HTML escaper — values in readouts may contain trusted HTML + // (e.g. 32.0°C) so they are rendered with + // innerHTML; all other user/model strings go through _opsESC. + _opsESC(s) { + return String(s == null ? '' : s) + .replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + }, + + // Entry point: render the operation spine into `root`. + // plan = the plan object (tactics array) or null for idle/unavailable. + _renderOperationSpine(root, plan) { + const ESC = this._opsESC.bind(this); + + if (!plan || !Array.isArray(plan.tactics) || plan.tactics.length === 0) { + root.innerHTML = ` +
    +
    Operations
    +

    No operation running

    +
    When the agent begins operating by tactic, the plan and live progress appear here.
    +
    Idle — no tactics planned or in use.
    +
    `; + return; + } + + const tactics = plan.tactics; + const hasActive = tactics.some(t => t.state === 'active'); + // Index of the first queued (planned) tactic — gets the "next" badge. + const firstPlannedIdx = tactics.findIndex(t => t.state === 'planned'); + + const spineNodes = tactics + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC)) + .join(''); + + root.innerHTML = ` +
    +
    Operations · ${hasActive ? 'live' : 'idle'}
    +

    ${ESC(plan.title || '')}

    +
    ${ESC(plan.session_id || '')}${plan.goal ? ' · ' + ESC(plan.goal) : ''}
    +
    + done + in use + queued +
    +
    ${spineNodes}
    +
    `; + }, + + // Render a single tactic node. + _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { + const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued' }; + const seq = String(t.seq || idx + 1).padStart(2, '0'); + const stateLabel = STATE_LABEL[t.state] || t.state; + // First queued tactic gets a "next" badge — COCKED instrument marker. + const isFirstQueued = t.state === 'planned' && idx === firstPlannedIdx; + const nextBadge = isFirstQueued + ? 'next' + : ''; + + const live = t.live || {}; + const target = live.target || ''; + const summary = live.summary || ''; + const desc = live.desc || ''; + + // Header row: name · target · summary + let inner = ` +
    + ${ESC(t.name)} + ${target ? `${ESC(target)}` : ''} + ${summary ? `${ESC(summary)}` : ''} +
    + ${desc ? `
    ${ESC(desc)}
    ` : ''}`; + + // AUDIT: FLATTEN the active card — readouts on the panel face, separated by + // a hairline rule. No nested card-in-card boxes. + if (t.state === 'active' && live.readouts && live.readouts.length) { + inner += `
    +
    + ${live.readouts.map(r => this._renderOpsReadout(r, ESC)).join('')} +
    `; + } + + // Kind-specific structure for the active state. + if (t.state === 'active') { + inner += this._renderOpsKindActive(t, live, ESC); + } else if (t.state === 'planned') { + inner += this._renderOpsKindPlanned(t, ESC); + } + + return ` +
    +
    ${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
    +
    ${inner}
    +
    `; + }, + + // Render a readout gauge. `r.value` may contain trusted HTML (span markup). + _renderOpsReadout(r, ESC) { + return `
    +
    ${ESC(r.label)}
    +
    ${r.value}
    + ${r.bar != null + ? `
    ` + : ''} + ${r.sub ? `
    ${ESC(r.sub)}
    ` : ''} +
    `; + }, + + // Render one phase in the scripted_protocol stepper. + // AUDIT: the active phase is the HEADLINE — CSS makes it larger. + _renderOpsPhase(p, ESC) { + const pips = (p.pips || []) + .map(k => ``) + .join(''); + const ic = p.state === 'done' ? '✓' + : p.state === 'active' ? '▶' + : (p.icon || '·'); + return `
    +
    ${ic}${ESC(p.name)}
    +
    ${ESC(p.count || '')}
    + ${pips ? `
    ${pips}
    ` : ''} +
    `; + }, + + // Kind-specific structure for ACTIVE tactics. + _renderOpsKindActive(t, live, ESC) { + if (!t.kind) return ''; + + // scripted_protocol → before/during/after phase stepper. + // Prefer live.phases (may carry pip/count state); fall back to structure.phases. + if (t.kind === 'scripted_protocol') { + const phases = live.phases || (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
    + ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
    `; + } + + // standing_timelapse → compact per-embryo cadence strip. + if (t.kind === 'standing_timelapse') { + const perEmbryo = t.structure && t.structure.per_embryo; + if (!perEmbryo || !perEmbryo.length) return ''; + const rows = perEmbryo.map(e => { + const intervalStr = e.interval_s != null ? `${ESC(e.interval_s)}s` : '—'; + return `
    + ${ESC(e.embryo_id)} + ${ESC(e.cadence_phase)} + ${intervalStr} +
    `; + }).join(''); + return `
    ${rows}
    `; + } + + // reactive_monitor → armed/watching/fired status badge. + if (t.kind === 'reactive_monitor') { + const st = (t.structure && t.structure.status) || 'armed'; + return `
    ${ESC(st)}
    `; + } + + // exclusive_burst / oneshot / custom — readouts only (already rendered above). + return ''; + }, + + // Kind-specific structure for PLANNED (queued) tactics — compact hints. + _renderOpsKindPlanned(t, ESC) { + if (!t.kind) return ''; + + if (t.kind === 'scripted_protocol') { + const phases = (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
    + ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
    `; + } + + if (t.kind === 'standing_timelapse' && t.structure && t.structure.cadence_s) { + return `
    cadence · ${ESC(t.structure.cadence_s)}s
    `; + } + + if (t.kind === 'reactive_monitor' && t.structure && t.structure.watch) { + return `
    watch · ${ESC(t.structure.watch)}
    `; + } + + if ((t.kind === 'oneshot' || t.kind === 'custom') && t.structure && t.structure.note) { + return `
    ${ESC(t.structure.note)}
    `; + } + + return ''; + }, + // ----------------------------------------------------------------- // Rules table — full subtab view, grouped by rule kind // ----------------------------------------------------------------- diff --git a/gently/ui/web/static/js/operations-scenarios.js b/gently/ui/web/static/js/operations-scenarios.js new file mode 100644 index 00000000..9a4f74e7 --- /dev/null +++ b/gently/ui/web/static/js/operations-scenarios.js @@ -0,0 +1,371 @@ +/** + * Operation Plan scenario fixtures — development and Chrome-MCP audit targets. + * + * Each entry is a plan object matching the real API schema returned by + * GET /api/operation_plan/{session_id} → { available, plan } + * The `.plan` is what gets passed to the renderer. Active tactics carry a + * `live` field (readouts + phases) that the API route merges from live + * telemetry; here they are baked into the fixture. + * + * Scenario dev mode: load via ?scenario= — ExperimentOverview reads + * window.OPERATIONS_SCENARIOS[name] and skips all fetches. + * + * Scenarios: + * temp_strain — scripted_protocol active (temp-change burst protocol) + * expression_onset — reactive_monitor active (reporter rising) + * hatching_detect — reactive_monitor active (watch=hatching, status=watching) + * transmission_survey — exclusive_burst active (brightfield only) + * decided_plan — all planned, nothing run yet + * async_multi — standing_timelapse per-embryo cadence + layered reactive_monitor + * idle — null (no operation running) + */ +window.OPERATIONS_SCENARIOS = { + + /* ------------------------------------------------------------------ */ + temp_strain: { + session_id: '20260628_1432_tempstrain_a', + title: 'Temperature-strain run · E01', + goal: 'Acquire volumes before, during, and after a +4 °C step to 32 °C; capture reporter response to heat stress.', + tactics: [ + { + id: 'ts-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before thermal perturbation.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '22 min · ended on signal' }, + relations: {} + }, + { + id: 'ts-2', seq: 2, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'done', + scope: { mode: 'global' }, + rationale: 'Brightfield snapshot before setpoint change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { summary: '3 bursts · brightfield' }, + relations: {} + }, + { + id: 'ts-3', seq: 3, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'active', + scope: { mode: 'global' }, + rationale: 'Systematic volume capture before, during ramp, and after thermal lock. Laser off during ramp to limit phototoxicity.', + structure: { + phases: [ + { name: 'before', state: 'done', count: '1/1 done' }, + { name: 'during', state: 'active', count: '2 · awaiting lock' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { + target: '→ 32.0 °C', + summary: 'started 3m ago', + desc: 'bursts before · setpoint change · bursts through ramp · bursts after lock — laser off', + readouts: [ + { + label: 'stage temp', + value: '29.432.0°C', + bar: 62 + }, + { + label: 'current burst', + value: '#3 during', + sub: '60f · 1Hz · brightfield' + } + ], + phases: [ + { name: 'before', state: 'done', count: '1/1 done', pips: ['before'] }, + { name: 'during', state: 'active', count: '2 · awaiting lock', pips: ['during', 'during', 'pending'] }, + { name: 'after', state: 'todo', count: '0/1', pips: ['pending'] } + ] + }, + relations: {} + }, + { + id: 'ts-4', seq: 4, + name: 'Recovery monitor — low cadence', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume gentle monitoring once temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { + summary: 'queued · 30 min after lock', + desc: 'resume gentle monitoring once temperature settles' + }, + relations: { after: ['ts-3'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + expression_onset: { + session_id: '20260628_0915_onset_b', + title: 'Reporter-onset watch · E04', + goal: 'Detect first appearance of the fluorescent reporter; capture onset dynamics at high temporal resolution.', + tactics: [ + { + id: 'eo-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before signal appears.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '1h 40m · baseline' }, + relations: {} + }, + { + id: 'eo-2', seq: 2, + name: 'Expression monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Accelerate cadence on signal, ramp 488 down on saturation, burst on stable structure.', + structure: { watch: 'reporter onset', reaction: 'accelerate + ramp laser', status: 'watching' }, + live_bind: ['signal', 'cadence'], + live: { + target: 'reporter onset', + summary: 'signal rising', + desc: 'accelerate cadence on signal · ramp 488 down on saturation · burst on stable structure', + readouts: [ + { + label: 'reporter signal', + value: 'rising', + sub: '+14% over 6 min', + bar: 48 + }, + { + label: 'cadence', + value: '120s 30s', + sub: 'accelerated on onset' + }, + { + label: '488 power', + value: '5% 3%', + sub: 'ramped to limit saturation' + } + ] + }, + relations: {} + }, + { + id: 'eo-3', seq: 3, + name: 'Burst on good structure', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Capture a burst once the reporter pattern holds.', + structure: { frames: 60, mode: 'fluorescence' }, + live_bind: [], + live: { + summary: 'queued · when structure stable', + desc: 'capture a burst once the reporter pattern holds' + }, + relations: { after: ['eo-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + hatching_detect: { + session_id: '20260627_2210_hatch_c', + title: 'Pre-hatching vigil · E11', + goal: 'Detect and capture the hatching event; accelerate acquisition as hatching approaches.', + tactics: [ + { + id: 'hd-1', seq: 1, + name: 'Pre-terminal monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Low cadence now; speed up as hatching approaches.', + structure: { watch: 'hatching', reaction: 'accelerate near event', status: 'watching' }, + live_bind: ['cadence'], + live: { + target: 'hatching', + summary: 'watching', + desc: 'low cadence now · speed up as hatching approaches', + readouts: [ + { label: 'est. time to hatch', value: '~38 min', sub: 'from motion + morphology' }, + { label: 'cadence', value: '180s', sub: 'will speed up near event' } + ] + }, + relations: {} + }, + { + id: 'hd-2', seq: 2, + name: 'Hatching speedup', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'High cadence through hatching.', + structure: { cadence_s: 30 }, + live_bind: ['cadence'], + live: { + summary: 'queued · ~T-10 min', + desc: 'high cadence through hatching' + }, + relations: { after: ['hd-1'] } + }, + { + id: 'hd-3', seq: 3, + name: 'Post-hatch monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume normal cadence after the event.', + structure: { cadence_s: 120 }, + live_bind: ['cadence'], + live: { summary: 'queued · after event' }, + relations: { after: ['hd-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + transmission_survey: { + session_id: '20260628_1100_survey_a', + title: 'Transmission survey · plate A', + goal: 'Survey all embryos with brightfield only; no laser excitation.', + tactics: [ + { + id: 'srv-1', seq: 1, + name: 'Transmission burst', + kind: 'exclusive_burst', state: 'active', + scope: { mode: 'global' }, + rationale: 'LED/brightfield bursts, no laser — DIC-like contrast.', + structure: { frames: 30, mode: 'brightfield', phase: 'capturing' }, + live_bind: ['current_burst'], + live: { + summary: 'capturing', + desc: 'LED/brightfield bursts, no laser — DIC-like contrast', + readouts: [ + { label: 'bursts captured', value: '7', sub: 'across 3 embryos' }, + { label: 'illumination', value: 'LED · laser off', sub: 'brightfield' } + ] + }, + relations: {} + }, + { + id: 'srv-2', seq: 2, + name: 'Volume at best plane', + kind: 'oneshot', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Full volume capture at the best focal plane after operator review.', + structure: { note: 'operator selects plane' }, + live_bind: [], + live: { summary: 'queued · operator review' }, + relations: { after: ['srv-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + decided_plan: { + session_id: '20260628_1500_tempstrain_b', + title: 'Temperature-strain run · E02', + goal: 'Repeat the thermal strain protocol on a second embryo cohort.', + tactics: [ + { + id: 'dp-1', seq: 1, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Brightfield baseline before any change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { + summary: 'queued · first', + desc: 'brightfield baseline before any change' + }, + relations: {} + }, + { + id: 'dp-2', seq: 2, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Thermal step to 30.0 °C with phased acquisition.', + structure: { + phases: [ + { name: 'before', state: 'todo', count: '0/1' }, + { name: 'during', state: 'todo', count: '0/3' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { target: '→ 30.0 °C', summary: 'queued · second' }, + relations: { after: ['dp-1'] } + }, + { + id: 'dp-3', seq: 3, + name: 'Recovery monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Low-cadence monitoring after temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: 'queued · last' }, + relations: { after: ['dp-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + async_multi: { + session_id: '20260628_1630_async_multi', + title: 'Async multi-embryo run · 4 embryos', + goal: 'Per-embryo asynchronous acquisition with individual cadence phases; overlay a hatching watch on the two most advanced.', + tactics: [ + { + id: 'am-1', seq: 1, + name: 'Async timelapse — per-embryo cadence', + kind: 'standing_timelapse', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02', 'E03', 'E04'] }, + rationale: 'Each embryo runs at its own cadence based on developmental stage and reporter state.', + structure: { + cadence_s: 120, + per_embryo: [ + { embryo_id: 'E01', cadence_phase: 'normal', interval_s: 180 }, + { embryo_id: 'E02', cadence_phase: 'fast', interval_s: 30 }, + { embryo_id: 'E03', cadence_phase: 'burst', interval_s: 0 }, + { embryo_id: 'E04', cadence_phase: 'paused', interval_s: null } + ] + }, + live_bind: ['cadence'], + live: { + summary: 'running · 4 embryos', + readouts: [ + { label: 'active embryos', value: '3 / 4', sub: 'E04 paused' }, + { label: 'cadence range', value: '30–180s', sub: 'per-embryo mode' } + ] + }, + relations: {} + }, + { + id: 'am-2', seq: 2, + name: 'Hatching watch — E01, E02', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02'] }, + rationale: 'Overlay a hatching detector on the two most advanced embryos.', + structure: { watch: 'hatching', reaction: 'accelerate + alert', status: 'armed' }, + live_bind: ['signal'], + live: { + target: 'hatching', + summary: 'armed · E01, E02', + desc: 'watching for hatching onset on the two most advanced embryos', + readouts: [ + { label: 'watch status', value: 'armed', sub: 'no event yet' }, + { label: 'scope', value: 'E01, E02', sub: '2 of 4 embryos' } + ] + }, + relations: { layered_on: ['am-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + idle: null +}; diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 7edcd230..1f09ba06 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -931,6 +931,7 @@

    Properties

    + From e65834db6da722ef8072ec7d0830a475400e3fbc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:12:46 +0530 Subject: [PATCH 123/200] feat(operations): live telemetry binding + event-driven refresh - ExperimentOverview now subscribes to CONTEXT_UPDATED + 7 tactic-lifecycle events (TEMP_PROTOCOL_STARTED/COMPLETED, BURST_START/COMPLETE, EMBRYO_CADENCE_CHANGED, TEMPERATURE_SETPOINT_CHANGED, POWER_RAMP_STEP); each fires a debounced (500 ms) loadPlan()+render() refetch. - _debouncedRefresh(): coalesces rapid bursts into a single fetch+render. _subscribed flag prevents double-registration on repeated tab re-clicks. Subscriptions are skipped entirely in scenario mode (?scenario=). - _handleTempUpdate(): TEMPERATURE_UPDATE (~1 Hz) binds water_c/setpoint_c directly onto the active scripted_protocol tactic's temperature gauge IN PLACE (updates .ops-gv only), with no plan refetch. - _renderOpsReadout(): stamps data-livebind on each gauge div. Explicit r.bind key takes priority over normalised r.label, so display labels ("stage temp") and semantic bind keys ("temperature") are decoupled. - operations-scenarios.js: adds bind:'temperature' to the temp_strain stage-temp readout so the in-place selector resolves correctly. --- .../ui/web/static/js/experiment-overview.js | 91 ++++++++++++++++++- .../ui/web/static/js/operations-scenarios.js | 1 + 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index c46eba9c..a42bb022 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -16,6 +16,9 @@ const ExperimentOverview = { activePlan: null, // last fetched/loaded operation plan (overview spine) isLive: false, // true when data came from the API scenarioMode: false, // true when ?scenario= is active + _subscribed: false, // guard: prevents double-registration across tab re-clicks + _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch + _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); @@ -46,6 +49,32 @@ const ExperimentOverview = { this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; + + // Subscribe to tactic-state events once per page load. + // Guard: _subscribed prevents double-registration across tab re-clicks. + // Skip entirely in scenario mode — no live backend, no websocket. + if (!this._subscribed) { + this._subscribed = true; + const refresh = () => this._debouncedRefresh(); + // Plan-changing events: re-fetch the whole plan after debounce. + // CONTEXT_UPDATED fires when OperationPlanUpdater patches the plan. + // The tactic-lifecycle events fire on transitions the updater also + // reacts to, so they all funnel into the same debounced refetch. + const TACTIC_EVENTS = [ + 'CONTEXT_UPDATED', + 'TEMP_PROTOCOL_STARTED', 'TEMP_PROTOCOL_COMPLETED', + 'BURST_START', 'BURST_COMPLETE', + 'EMBRYO_CADENCE_CHANGED', 'TEMPERATURE_SETPOINT_CHANGED', + 'POWER_RAMP_STEP', + ]; + TACTIC_EVENTS.forEach(ev => ClientEventBus.on(ev, refresh)); + + // High-frequency temperature binding (~1 Hz). + // Updates the active scripted_protocol tactic's temperature gauge + // IN PLACE — no plan refetch, no full re-render. + this._tempUpdateHandler = (data) => this._handleTempUpdate(data); + ClientEventBus.on('TEMPERATURE_UPDATE', this._tempUpdateHandler); + } }, async loadStrategy() { @@ -85,6 +114,57 @@ const ExperimentOverview = { } }, + // Debounced plan refetch — coalesces rapid tactic-event bursts into a single + // fetch+render. 500 ms window matches experiment-strip.js convention. + _debouncedRefresh() { + if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); + this._planRefreshTimer = setTimeout(async () => { + this._planRefreshTimer = null; + const plan = await this.loadPlan(); + this.activePlan = plan; + this.isLive = plan !== null; + this.render(this.activeStrategy); + }, 500); + }, + + // In-place temperature gauge update — called at ~1 Hz by TEMPERATURE_UPDATE. + // Finds the active scripted_protocol tactic's temperature readout in the DOM + // and rewrites only that element's value, never refetching the plan. + // No-op when there is no active scripted_protocol tactic with temperature binding. + _handleTempUpdate(data) { + if (!data || !data.sample) return; + const plan = this.activePlan; + if (!plan || !Array.isArray(plan.tactics)) return; + // Only act when an active scripted_protocol tactic declares temperature binding. + const activeTactic = plan.tactics.find( + t => t.state === 'active' + && t.kind === 'scripted_protocol' + && Array.isArray(t.live_bind) + && t.live_bind.includes('temperature') + ); + if (!activeTactic) return; + + const root = document.getElementById('experiment-overview-root'); + if (!root) return; + // _renderOpsReadout stamps data-livebind="temperature" on the gauge div + // when the readout label normalises to "temperature". + const gauge = root.querySelector('.ops-node.active .ops-gauge[data-livebind="temperature"]'); + if (!gauge) return; + const gv = gauge.querySelector('.ops-gv'); + if (!gv) return; + + const s = data.sample; + const water = s.water_c != null + ? parseFloat(s.water_c).toFixed(1) + '°C' + : '—'; + const sp = s.setpoint_c != null + ? ' → ' + + parseFloat(s.setpoint_c).toFixed(1) + + '°C' + : ''; + gv.innerHTML = water + sp; + }, + setView(view) { if (view === this.activeView) return; this.activeView = view; @@ -1228,8 +1308,17 @@ const ExperimentOverview = { }, // Render a readout gauge. `r.value` may contain trusted HTML (span markup). + // Stamps data-livebind on the outer div so _handleTempUpdate (and future + // live-binding) can find the gauge in-place without a full re-render. + // Priority: r.bind (explicit semantic key) > normalised r.label. _renderOpsReadout(r, ESC) { - return `
    + const bindKey = r.bind + ? r.bind + : (r.label + ? r.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + : ''); + const bindAttr = bindKey ? ` data-livebind="${ESC(bindKey)}"` : ''; + return `
    ${ESC(r.label)}
    ${r.value}
    ${r.bar != null diff --git a/gently/ui/web/static/js/operations-scenarios.js b/gently/ui/web/static/js/operations-scenarios.js index 9a4f74e7..0cd72fbf 100644 --- a/gently/ui/web/static/js/operations-scenarios.js +++ b/gently/ui/web/static/js/operations-scenarios.js @@ -70,6 +70,7 @@ window.OPERATIONS_SCENARIOS = { readouts: [ { label: 'stage temp', + bind: 'temperature', value: '29.432.0°C', bar: 62 }, From ab4395c2be8f8c36bf7482659801061d702fc189 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:29:43 +0530 Subject: [PATCH 124/200] fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - templates.py: add OPERATION_PLAN_GUIDANCE block + inject after REACTIVE_MONITORING_MODES - agent.py exit_plan_mode: call seed_operation_plan_from_plan_item after link_session_campaign - timelapse_tools.py queue_burst: gate transition_tactic on result.startswith("Burst queued for ") - operation_plan_tools.py: add "paused" to _VALID_STATES (pause_timelapse uses it) - file_store.py transition_tactic: add read-modify-write / single-loop-thread note - spec: document live.readouts/phases + flat bound keys in §1 tactic schema - test: add test_queue_burst_soft_reject_does_not_transition (Fix #4 coverage) --- .superpowers/sdd/dv3-whole-branch-fixes.md | 109 ++++++++++++++++++ .../specs/2026-06-28-operations-tab-design.md | 8 ++ gently/app/agent.py | 10 ++ gently/app/tools/operation_plan_tools.py | 2 +- gently/app/tools/timelapse_tools.py | 8 +- gently/harness/memory/file_store.py | 4 + gently/harness/prompts/templates.py | 26 +++++ tests/test_tool_tactic_linkage.py | 28 +++++ 8 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 .superpowers/sdd/dv3-whole-branch-fixes.md diff --git a/.superpowers/sdd/dv3-whole-branch-fixes.md b/.superpowers/sdd/dv3-whole-branch-fixes.md new file mode 100644 index 00000000..5ec58d80 --- /dev/null +++ b/.superpowers/sdd/dv3-whole-branch-fixes.md @@ -0,0 +1,109 @@ +# dv3 whole-branch review fixes — backend half + +Agent: backend-fixes agent (this session). +Commit: see footer. +Frontend fixes (experiment-overview.js + css): separate agent, not touched here. + +--- + +## Fix #2 — agent prompt guidance to declare the Operation Plan + +**File:** `gently/harness/prompts/templates.py` + +Added constant `OPERATION_PLAN_GUIDANCE` (~line 225) and injected `{OPERATION_PLAN_GUIDANCE}` +into the system template f-string right after `{REACTIVE_MONITORING_MODES}` (~line 510). +The block tells the agent to call `declare_operation_plan` at experiment planning time +with kind/name/target/scope/rationale + a `live` object (`readouts`/`phases` + flat keys), +and to re-call (patch) on each tactic transition. Also notes that execution tools +(`queue_burst`, `enable_monitoring_mode`, etc.) accept `tactic_id` and flip state automatically. + +--- + +## Fix #3 — wire operation-plan seeding in exit_plan_mode + +**File:** `gently/app/agent.py`, `exit_plan_mode()` (~line 475) + +Hook site confirmed: inside `if active_id:` block, right after the `link_session_campaign` +try/except (line ~464-469). At that point `self.context_store` and `self.session_id` are +guaranteed valid (context_store is used unguarded at `get_plan_item` one line above). + +Added: +```python +try: + from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + seed_operation_plan_from_plan_item(self.context_store, self.session_id) +except Exception: + logger.exception("operation-plan seeding failed") +``` + +--- + +## Fix #4 — queue_burst phantom-active on soft-reject + +**File:** `gently/app/tools/timelapse_tools.py` (~line 1204) + +`orchestrator.queue_burst` returns `"Burst queued for {embryo_id} (request_id=..., ...)"` on +success and a rejection sentence (embryo not in timelapse / already had burst / already queued) +on soft-reject. Success is reliably distinguishable by `result.startswith("Burst queued for ")`. + +Guard applied: +```python +if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): + cs.transition_tactic(session, tactic_id, "active") +``` + +New test added to `tests/test_tool_tactic_linkage.py`: +`test_queue_burst_soft_reject_does_not_transition` — fake orchestrator returns +`"Embryo 'emb1' already has a queued burst."` → verifies `cs.transitions == []`. + +--- + +## Fix #5 — `paused` valid state + +**File:** `gently/app/tools/operation_plan_tools.py`, line 29 + +`_VALID_STATES` extended: `frozenset({"planned", "active", "done", "paused"})`. +`pause_timelapse` transitions to `"paused"` — this was previously an invalid state +causing a ValueError on validation. + +--- + +## Fix #7 — document `live` in the spec + +**File:** `docs/superpowers/specs/2026-06-28-operations-tab-design.md`, §1 + +Added `live` field to the tactic schema block with `readouts`/`phases` sub-keys and +a documentation paragraph explaining that the agent authors `readouts`/`phases` at +declaration time and the updater merges flat bound keys (`request_id`, `sustained_hz`, +`setpoint`, `locked`, `last_fired`, `new_phase`, …) as telemetry arrives. + +--- + +## Fix #8 — transition_tactic single-loop note + +**File:** `gently/harness/memory/file_store.py`, `transition_tactic` docstring (~line 847) + +Added one-line note: "read-modify-write with no lock; safe because all subscribed event +emissions run on the single asyncio loop thread — revisit if a worker-thread emitter +is ever added." + +--- + +## Test results + +``` +pytest tests/test_operation_plan_tool.py tests/test_tool_tactic_linkage.py \ + tests/test_operation_plan_seeding.py tests/test_operation_plan_store.py -v +65 passed, 0 failed +``` + +Full suite (excluding 3 pre-existing collection errors in test_campaign_coordination.py, +test_gently_store.py, test_text_tool_call_extraction.py): +- Before changes: 32 failed, 673 passed, 19 skipped +- After changes: 32 failed, 674 passed, 19 skipped (+1 new test, 0 new failures) + +--- + +## Commit + +`fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs` diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md index 95224ed1..d301e312 100644 --- a/docs/superpowers/specs/2026-06-28-operations-tab-design.md +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -42,8 +42,16 @@ structure : # by kind oneshot|custom → { note } live_bind : [ 'temperature' | 'current_burst' | 'cadence' | 'signal' | ... ] # which telemetry fills readouts/progress relations : { after?:[tactic_id], layered_on?:[tactic_id], triggers?:[tactic_id] } # concurrency + composition +live : { # agent-authored display hints + updater-merged telemetry + readouts : [{label, value, sub?, bar?, bind?}] # instrument strip entries (e.g. {label:"cadence", value:"120 s"}) + phases : [{name, state:'done'|'active'|'todo', count, pips}] # phase stepper for scripted_protocol / exclusive_burst + # flat bound keys merged in by the updater as telemetry arrives: + # request_id, sustained_hz, setpoint, locked, last_fired, new_phase, ... +} ``` +The `live` object has two agent-authored sub-keys: `readouts` (a list of instrument-strip rows the agent populates at declaration time) and `phases` (a phase stepper list for scripted or burst tactics). The operation-plan updater additionally merges flat keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, `last_fired`, `new_phase`, and any other telemetry bound via `live_bind`) directly onto `live` as events arrive — the agent seeds these at declaration if the value is already known, or leaves them absent for the updater to fill in. + This single typed object expresses a regular timelapse (standing), an async per-embryo run (standing with per-embryo cadence — *real* in the orchestrator), a reactive monitor incl. **hatching detection** (reactive), a temp-change protocol (scripted, phased), an exclusive burst, and arbitrary `custom` diff --git a/gently/app/agent.py b/gently/app/agent.py index b02b227c..47b2421d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -467,6 +467,16 @@ def exit_plan_mode(self) -> str: ) except Exception: pass + + # Seed the Operation Plan from the plan item's tactics outline + # (idempotent: no-op if plan already has active/done tactics). + try: + from gently.app.tools.operation_plan_seed import ( + seed_operation_plan_from_plan_item, + ) + seed_operation_plan_from_plan_item(self.context_store, self.session_id) + except Exception: + logger.exception("operation-plan seeding failed") elif candidates: titles = [c[0].title for c in candidates] listing = ", ".join(titles[:5]) diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py index 07d51835..93998a03 100644 --- a/gently/app/tools/operation_plan_tools.py +++ b/gently/app/tools/operation_plan_tools.py @@ -26,7 +26,7 @@ "custom", } ) -_VALID_STATES = frozenset({"planned", "active", "done"}) +_VALID_STATES = frozenset({"planned", "active", "done", "paused"}) def _validate_tactics(tactics: list) -> list[dict]: diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index 9e96cbe5..0c0c45c2 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -1201,8 +1201,12 @@ def queue_burst( force=force, tactic_id=tactic_id, ) - # Flip the matching plan tactic to active on enqueue (guarded no-op when absent). - if tactic_id: + # Flip the matching plan tactic to active only when the burst was actually queued. + # orchestrator.queue_burst returns "Burst queued for ..." on success and a + # human-readable rejection sentence on soft-reject (embryo absent, already had + # a burst, already has a queued burst). Gate on the success prefix so a + # soft-reject does NOT phantom-flip the tactic to active. + if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): cs = getattr(agent, "context_store", None) session = getattr(agent, "session_id", None) if cs and session: diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 98763cf3..7a9eba4e 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -842,6 +842,10 @@ def transition_tactic( Returns True on success, False if the plan is absent or the tactic id is not found (no-op, no crash). + + Note: read-modify-write with no lock; safe because all subscribed event + emissions run on the single asyncio loop thread — revisit if a + worker-thread emitter is ever added. """ plan = self.get_operation_plan(session_id) if plan is None: diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index f75fc7a5..536064b4 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -222,6 +222,30 @@ """ +OPERATION_PLAN_GUIDANCE = """ +## Operation Plan — keep it current + +At experiment planning time, call `declare_operation_plan` with every tactic +you intend to run. Each tactic needs at minimum: `id` (short stable string), +`name`, `kind`, `state` (start as `"planned"`), `scope`, and `rationale`. +For richer display, populate a `live` object on the tactic: + +- `readouts` — list of `{label, value}` dicts for the instrument strip + (e.g. `{label: "cadence", value: "120 s"}`). +- `phases` — list of `{name, state, count, pips}` for scripted/phased tactics. +- Flat bound keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, + `last_fired`, `new_phase`, …) are merged in by the updater as live telemetry + arrives; you can seed them at declaration time if the value is already known. + +Re-call `declare_operation_plan` (patch) whenever a tactic's state changes: +`"planned"` → `"active"` when you start it, `"active"` → `"done"` when it +finishes. This keeps the Operations view in the UI synchronized with reality. +Execution tools (`queue_burst`, `enable_monitoring_mode`, `stop_timelapse`, +`pause_timelapse`) also accept an optional `tactic_id` and flip the state +automatically — pass it when a tool maps cleanly to one tactic. +""" + + ADAPTIVE_TIMELAPSE = """ # Adaptive Timelapse System @@ -483,6 +507,8 @@ def build_system_prompt( {REACTIVE_MONITORING_MODES} +{OPERATION_PLAN_GUIDANCE} + {AUTONOMY_AND_ADAPTATION} {USER_INTERACTION_GUIDELINES} diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py index be245093..6b56fa47 100644 --- a/tests/test_tool_tactic_linkage.py +++ b/tests/test_tool_tactic_linkage.py @@ -185,6 +185,34 @@ async def test_queue_burst_passes_tactic_id_to_orchestrator(): assert orch.bursts_queued[0]["tactic_id"] == "t2" +@pytest.mark.asyncio +async def test_queue_burst_soft_reject_does_not_transition(): + """queue_burst (tool) must NOT flip the tactic to active on a soft-reject. + + orchestrator.queue_burst returns a rejection sentence (not starting with + "Burst queued for") when the embryo already has a queued burst, already + had a burst this session, or is not in the active timelapse. The tool + must treat those as non-events and leave transition_tactic uncalled. + """ + from gently.app.tools.timelapse_tools import queue_burst + + class RejectingOrchestrator(FakeOrchestrator): + def queue_burst(self, embryo_id, *, frames=60, mode="1hz", num_slices=1, + force=False, laser_config=None, tactic_id=None) -> str: + # Simulates "already has a queued burst" soft-reject + return f"Embryo '{embryo_id}' already has a queued burst." + + cs = FakeContextStore() + orch = RejectingOrchestrator() + agent = FakeAgent(cs=cs, orchestrator=orch) + + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], ( + f"transition_tactic must not be called on soft-reject; got {cs.transitions}" + ) + + # --------------------------------------------------------------------------- # stop_timelapse — mark done # --------------------------------------------------------------------------- From f4ab6064c6739d60bf9ddc0dea840a5c8175163a Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:32:37 +0530 Subject: [PATCH 125/200] fix(operations): surface bound live.* telemetry, paused state styling, delete dead swimlane code Fix #1: _renderOpsTactic now collects all flat keys on live{} (excl. readouts/phases/target/summary/desc) and renders them as a compact .ops-livefacts key:value strip for active and done tactics. Bound telemetry like sustained_hz, mp4_path, last_fired, request_id is now visible on the card face instead of being swallowed. Fix #5: added paused to STATE_LABEL; .ops-node.paused gets a muted grey dot + solid grey left edge distinct from done/active/planned. Fix #6: deleted 897 lines of unreachable swimlane dead code: _renderOverviewView, _renderHeader, _renderLegend, _renderSwimlanes, _renderLaneRow, _startNowTicker, _updateNowMarker. render() has called _renderOperationSpine for the overview path since the spine rewrite; all deleted functions were confirmed unreferenced outside their own chain. --- gently/ui/web/static/css/experiment.css | 60 ++ .../ui/web/static/js/experiment-overview.js | 922 +----------------- 2 files changed, 78 insertions(+), 904 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 16f6ba73..1d48cef5 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -1074,3 +1074,63 @@ color: var(--text-muted); opacity: 0.75; } + +/* ---- Fix #1: bound live.* telemetry facts strip --------------- */ +/* Compact monospace key:value row rendered for active + done tactics + when the live object carries flat keys beyond readouts/phases/target. + Subtle instrument aesthetic — matches the ops-mono system, sits below + the structured readout strip without visual weight. */ + +.ops-livefacts { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + margin-top: 8px; + padding: 6px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 7px; + font-family: var(--ops-mono); + font-size: 11px; + line-height: 1.5; +} + +.ops-lf-pair { + display: inline-flex; + align-items: baseline; + gap: 5px; +} + +.ops-lf-k { + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: lowercase; +} + +.ops-lf-v { + color: var(--text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---- Fix #5: paused tactic state ----------------------------- */ +/* Muted grey dot + solid grey left edge — visually distinct from + done (green), active (amber), and queued (dashed blue). */ + +.ops-node.paused::before { + border-color: var(--text-muted, #8b949e); + background: var(--bg-card, #161b22); + opacity: 0.7; +} + +.ops-node.paused .ops-stagelab { + color: var(--text-muted, #8b949e); +} + +.ops-node.paused .ops-card { + background: transparent; + border-color: rgba(139, 148, 158, 0.3); + border-left: 3px solid var(--text-muted, #8b949e); + opacity: 0.8; +} diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index a42bb022..6ba9b109 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -212,20 +212,6 @@ const ExperimentOverview = { } }, - // The "now" marker advances with wall-clock time and shows a countdown to - // the next base-interval acquisition. We update only the marker group's - // transform + the chip text, never re-rendering the whole SVG. Tick rate - // is ~4 Hz which keeps the line motion visibly smooth without burning - // cycles. Skipped while the tab is hidden. - _startNowTicker() { - this._stopNowTicker(); - const tick = () => { - if (!this._nowTickerCtx) return; - if (!document.hidden) this._updateNowMarker(); - this._nowTickerHandle = setTimeout(tick, 250); - }; - this._nowTickerHandle = setTimeout(tick, 250); - }, _stopNowTicker() { if (this._nowTickerHandle) { @@ -234,56 +220,7 @@ const ExperimentOverview = { } }, - _updateNowMarker() { - const ctx = this._nowTickerCtx; - if (!ctx || !ctx.marker.isConnected) return; - const elapsedRealS = (Date.now() - ctx.renderedAtMs) / 1000; - const effOffsetS = Math.min( - ctx.renderedOffsetS + elapsedRealS, - ctx.horizonS - ); - const x = ctx.xForT(effOffsetS); - ctx.marker.setAttribute('transform', `translate(${x},0)`); - - // Wall-clock from session-anchored time so the line and the clock - // can't drift apart even if the client clock is wrong. - const wallMs = ctx.startedAtMs + effOffsetS * 1000; - const d = new Date(wallMs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - - let label = `${hh}:${mm}:${ss}`; - if (ctx.baseIntervalS > 0) { - const nextTickS = Math.ceil(effOffsetS / ctx.baseIntervalS) * ctx.baseIntervalS; - const remainS = Math.max(0, Math.round(nextTickS - effOffsetS)); - const rm = Math.floor(remainS / 60); - const rs = String(remainS % 60).padStart(2, '0'); - label += ` · next ${rm}:${rs}`; - } - ctx.chipText.textContent = label; - - // Size the chip to fit; flip to the left of the line if we're near - // the right edge so it stays on-screen. - const textLen = label.length * 6.2 + 12; - const nearEnd = x + textLen + 8 > ctx.laneRight; - if (nearEnd) { - ctx.chipBg.setAttribute('x', -textLen - 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', -textLen + 2); - } else { - ctx.chipBg.setAttribute('x', 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', 10); - } - }, - _renderOverviewView(root, s) { - root.appendChild(this._renderHeader(s)); - root.appendChild(this._renderModes(s)); - root.appendChild(this._renderModeExpanded(s)); - root.appendChild(this._renderSwimlanes(s)); - }, _renderRulesView(root, s) { // Compact header echoing the session identity @@ -302,48 +239,6 @@ const ExperimentOverview = { root.appendChild(this._renderRulesTable(s)); }, - // ----------------------------------------------------------------- - // Header — session identification + key metrics strip - // (page-level title lives in .experiment-header-bar above) - // ----------------------------------------------------------------- - _renderHeader(s) { - const elapsedH = Math.floor(s.now_offset_s / 3600); - const elapsedM = Math.floor((s.now_offset_s % 3600) / 60); - const wrap = el('div', 'expov-header'); - - // Session identification — the navbar already carries the id on - // every tab, so we only render a name line when it actually adds - // info (i.e. a human label, not a hash). The data-source badge is - // still useful and gets its own row so it stays visible. - const metaRow = el('div', 'expov-header-row expov-header-row-meta'); - if (s.session_name && s.session_name !== s.session_id) { - metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); - } - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - wrap.appendChild(metaRow); - - // Compact key-metric strip - const roleCounts = {}; - s.embryos.forEach(e => { roleCounts[e.role] = (roleCounts[e.role] || 0) + 1; }); - const roleStr = Object.entries(roleCounts).map(([r, n]) => `${n} ${r}`).join(' · '); - const metricsRow = el('div', 'expov-header-row expov-header-row-metrics'); - const metric = (label, val) => { - const m = el('span', 'expov-metric'); - m.appendChild(elText('span', 'expov-metric-val', val)); - m.appendChild(elText('span', 'expov-metric-lbl', label)); - return m; - }; - metricsRow.appendChild(metric('elapsed', `${elapsedH}h ${elapsedM}m`)); - metricsRow.appendChild(metric('base', `${s.base_interval_s}s`)); - const budgetText = (s.dose_budget_base_ms != null && isFinite(s.dose_budget_base_ms)) - ? `${(s.dose_budget_base_ms / 1000).toFixed(0)}s × role` - : 'no limit'; - metricsRow.appendChild(metric('budget', budgetText)); - metricsRow.appendChild(metric('embryos', `${s.embryos.length} · ${roleStr}`)); - wrap.appendChild(metricsRow); - - return wrap; - }, // ----------------------------------------------------------------- // Monitoring mode chips + expanded panel @@ -409,804 +304,6 @@ const ExperimentOverview = { return name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }, - // ----------------------------------------------------------------- - // Legend - // ----------------------------------------------------------------- - _renderLegend() { - const wrap = el('div', 'expov-legend'); - const items = [ - ['base', 'base cadence'], - ['fast', 'fast cadence'], - ['burst', 'burst window'], - ['cooldown', 'cooldown'], - ['paused', 'paused'], - ]; - items.forEach(([cls, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elClass('span', `expov-legend-swatch ${cls}`)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - const projItem = el('span', 'expov-legend-item'); - projItem.appendChild(elClass('span', 'expov-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - wrap.appendChild(projItem); - - const glyphs = [ - ['◇', 'trigger fired'], - ['●', 'now'], - ['■', 'stop condition'], - ['∞', 'open-ended'], - ['▲', 'burst start'], - ['⚠', 'budget warning'] - ]; - glyphs.forEach(([g, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elText('span', 'expov-legend-glyph', g)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - return wrap; - }, - - // ----------------------------------------------------------------- - // Swimlanes SVG — the main visualization - // ----------------------------------------------------------------- - _renderSwimlanes(s) { - const wrap = el('div', 'expov-swimlanes-wrap'); - - // Compact inline legend above the SVG - const legend = el('div', 'expov-mini-legend'); - const swatches = [ - ['base', 'base'], - ['fast', 'fast'], - ['burst', 'burst'], - ['cooldown', 'cooldown'] - ]; - swatches.forEach(([k, label]) => { - const item = el('span', 'expov-mini-legend-item'); - const sw = el('span', `expov-mini-legend-swatch ${k}`); - item.appendChild(sw); - item.appendChild(elText('span', '', label)); - legend.appendChild(item); - }); - const projItem = el('span', 'expov-mini-legend-item'); - projItem.appendChild(elClass('span', 'expov-mini-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - legend.appendChild(projItem); - wrap.appendChild(legend); - - // Layout constants (logical pixels in the SVG viewBox) - const LEFT = 180; // label gutter - const RIGHT = 80; // right gutter for stop icon + ∞ - const LANE_W = 900; // lane drawing area - const W = LEFT + LANE_W + RIGHT; - - const ROW_H = 100; // per-embryo row total height - const LANE_H = 28; // cadence lane height - const POWER_H = 22; // power strip height - const DOSE_H = 12; // dose gauge height - const ROW_PAD = 14; // top padding inside row - const TOP_AXIS_H = 36; // top axis area (time labels + wall-clock) - const BOTTOM_PAD = 8; - - const rows = s.embryos.length; - const H = TOP_AXIS_H + rows * ROW_H + BOTTOM_PAD; - - const svg = svgEl('svg', { - class: 'expov-swimlanes-svg', - viewBox: `0 0 ${W} ${H}`, - preserveAspectRatio: 'xMinYMin meet' - }); - - // Time scale helpers - const xForT = (t) => LEFT + (t / s.horizon_s) * LANE_W; - const nowX = xForT(s.now_offset_s); - - // ----- top axis: hour ticks with wall-clock annotation - const startedAt = new Date(s.started_at); - const axisG = svgEl('g'); - for (let h = 0; h <= Math.ceil(s.horizon_s / 3600); h++) { - const x = xForT(h * 3600); - axisG.appendChild(svgEl('line', { - x1: x, x2: x, y1: TOP_AXIS_H - 6, y2: H - BOTTOM_PAD, - class: 'expov-svg-axis', 'stroke-opacity': h === 0 ? 0.55 : 0.12 - })); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 12, class: 'expov-svg-axis-label' - }, `+${h}h`)); - // Wall-clock subtitle - const wallClock = new Date(startedAt.getTime() + h * 3600 * 1000); - const hh = String(wallClock.getHours()).padStart(2, '0'); - const mm = String(wallClock.getMinutes()).padStart(2, '0'); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 22, - class: 'expov-svg-axis-wallclock' - }, `${hh}:${mm}`)); - } - svg.appendChild(axisG); - - // ----- "now" marker: translatable group containing the vertical line - // and a live clock chip. The chip advances every tick and shows the - // countdown to the next base-interval acquisition window. The group - // gets translated by _tickNow, so we don't rebuild SVG every second. - const nowMarker = svgEl('g', { class: 'expov-svg-now-marker' }); - nowMarker.appendChild(svgEl('line', { - x1: 0, x2: 0, y1: TOP_AXIS_H - 4, y2: H - BOTTOM_PAD, - class: 'expov-svg-now-line' - })); - // Chip sits just below the axis labels (which live at y=12 and y=22) - // so it doesn't sit on top of the "+0h / wallclock" annotation when - // the now-line is near the start of the timeline. - const chipBg = svgEl('rect', { - x: 4, y: TOP_AXIS_H - 6, width: 120, height: 14, rx: 7, - class: 'expov-svg-now-chip-bg' - }); - const chipText = svgEl('text', { - x: 10, y: TOP_AXIS_H + 4, class: 'expov-svg-now-label' - }, ''); - nowMarker.appendChild(chipBg); - nowMarker.appendChild(chipText); - svg.appendChild(nowMarker); - - // Stash the bits the ticker needs to update without re-rendering. - this._nowTickerCtx = { - marker: nowMarker, - chipBg: chipBg, - chipText: chipText, - xForT, - startedAtMs: new Date(s.started_at).getTime(), - renderedAtMs: Date.now(), - renderedOffsetS: s.now_offset_s, - baseIntervalS: s.base_interval_s || 0, - horizonS: s.horizon_s, - laneLeft: LEFT, - laneRight: LEFT + LANE_W, - }; - this._updateNowMarker(); - - // ----- one group per embryo - s.embryos.forEach((emb, i) => { - const rowTop = TOP_AXIS_H + i * ROW_H; - const rowG = svgEl('g'); - rowG.appendChild(this._renderLaneRow(s, emb, { - LEFT, LANE_W, RIGHT, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - TOP_AXIS_H, rowTop, xForT, nowX - })); - svg.appendChild(rowG); - }); - - wrap.appendChild(svg); - return wrap; - }, - - _renderLaneRow(s, emb, dim) { - const { LEFT, LANE_W, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - rowTop, xForT, nowX } = dim; - const g = svgEl('g'); - - // Hairline divider above each row (except first) - if (rowTop > dim.TOP_AXIS_H) { - g.appendChild(svgEl('line', { - x1: 8, x2: W - 8, y1: rowTop, y2: rowTop, - class: 'expov-svg-row-divider' - })); - } - - // ---- Left label gutter --------------------------------------- - // Single header line + phase pill. Power/dose labels are at the - // y-position of their respective sub-rows, right-aligned in the gutter. - const labelY = rowTop + ROW_PAD + 12; - - // Header line: icon · ID · role (· 10× hint for calibration) - g.appendChild(svgEl('text', { - x: 14, y: labelY + 1, class: 'expov-svg-role-icon', - fill: emb.color - }, emb.icon)); - g.appendChild(svgEl('text', { - x: 32, y: labelY, class: 'expov-svg-label' - }, emb.id)); - // role tag — eyebrow above the id (avoids colliding with long ids) - g.appendChild(svgEl('text', { - x: 14, y: rowTop + ROW_PAD - 1, - class: 'expov-svg-role-tag' - }, emb.role)); - - // Phase pill: current mode at glance - const currentPhase = emb.phases[emb.phases.length - 1]; - const pillY = labelY + 7; - const pillH = 14; - const phaseLabel = currentPhase.mode === 'burst' - ? `BURST · ${currentPhase.hz}Hz` - : `${currentPhase.mode.toUpperCase()} · ${currentPhase.cadence_s}s`; - const pillW = Math.max(70, phaseLabel.length * 6 + 12); - const pillX = 32; - const phaseColors = { - base: '#6b7280', - fast: '#fb923c', - burst: '#ef4444', - cooldown: '#a78bfa', - paused: '#3b82f6' - }; - const pillFill = phaseColors[currentPhase.mode] || '#6b7280'; - g.appendChild(svgEl('rect', { - x: pillX, y: pillY, width: pillW, height: pillH, rx: 7, - fill: pillFill, 'fill-opacity': 0.22, - stroke: pillFill, 'stroke-opacity': 0.65, 'stroke-width': 1 - })); - g.appendChild(svgEl('text', { - x: pillX + pillW / 2, y: pillY + 10, - 'text-anchor': 'middle', - fill: pillFill, 'font-size': 9.5, 'font-weight': 700, - 'font-family': "'JetBrains Mono', monospace" - }, phaseLabel)); - - // Tiny tp annotation under the pill (no stop — that's at lane right edge) - g.appendChild(svgEl('text', { - x: 32, y: pillY + pillH + 12, - class: 'expov-svg-sublabel' - }, `${emb.tp_acquired} tp acquired`)); - - // ---- Cadence lane -------------------------------------------- - const laneY = rowTop + ROW_PAD; - const laneMid = laneY + LANE_H / 2; - const laneBottom = laneY + LANE_H; - - // Phases — solid colored rects, no ticks. Cadence is read from the - // phase pill in the gutter and the optional inline cadence label. - // Min 4px visual width so micro-phases (burst, cooldown) stay visible. - emb.phases.forEach(ph => { - const x0 = xForT(ph.start); - const x1Raw = xForT(ph.end); - const x1 = Math.max(x1Raw, x0 + 4); - const width = x1 - x0; - const cls = `expov-svg-phase-${ph.mode}`; - g.appendChild(svgEl('rect', { - x: x0, y: laneY, width, height: LANE_H, rx: 2, - class: cls - })); - // Cadence text inside the rect is intentionally omitted — the - // gutter pill (currentPhase) and the colored rect (mode) already - // convey it. Keep an inline label only for cooldown, which is a - // transient state the pill won't be showing. - if (ph.mode === 'cooldown' && ph.cadence_s && width >= 42) { - g.appendChild(svgEl('text', { - x: x0 + width / 2, y: laneMid + 3.5, - 'text-anchor': 'middle', - class: 'expov-svg-phase-label' - }, `${ph.cadence_s}s · cool`)); - } - // Burst: keep the bright block + balloon since it's the most - // attention-worthy event in the lane - if (ph.mode === 'burst') { - const bx = (xForT(ph.start) + xForT(ph.end)) / 2; - const balloonY = laneY - 12; - const halfW = 30; - g.appendChild(svgEl('rect', { - x: bx - halfW, y: balloonY - 10, width: halfW * 2, height: 12, rx: 3, - class: 'expov-svg-burst-balloon-bg' - })); - g.appendChild(svgEl('text', { - x: bx, y: balloonY - 1, - 'text-anchor': 'middle', - class: 'expov-svg-burst-label' - }, `${ph.frames}f · ${ph.hz}Hz`)); - g.appendChild(svgEl('line', { - x1: bx, x2: bx, y1: balloonY + 2, y2: laneY, - stroke: '#ef4444', 'stroke-width': 1, 'stroke-opacity': 0.7 - })); - } - }); - - // ---- Acquisition density heatmap ---------------------------- - // Instead of one hairline per acquisition (reads as a barcode), - // we encode acquisition density as a luminance gradient over - // the past portion of the lane: sparse = lane stays muted, - // dense = a brighter band. The eye reads acquisition rate as - // brightness — no discrete marks, no clutter. - // - // Counts are derived from phase cadence then rescaled to match - // the authoritative `tp_acquired`, so the gradient never lies - // about how many timepoints fired even when the backend phase - // history is stale or incorrect. - const tickEnd = s.now_offset_s; - const ackPhases = emb.phases - .map((ph, i) => ({ ph, i })) - .filter(({ ph }) => ph.mode !== 'burst' && ph.cadence_s); - const predicted = ackPhases.map(({ ph }) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - return dur > 0 ? Math.max(1, Math.floor(dur / ph.cadence_s) + 1) : 0; - }); - const predictedTotal = predicted.reduce((a, b) => a + b, 0); - const actualTotal = Number.isFinite(emb.tp_acquired) - ? emb.tp_acquired : predictedTotal; - const scale = predictedTotal > 0 ? actualTotal / predictedTotal : 0; - const acquisitions = []; - ackPhases.forEach(({ ph }, idx) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - if (dur <= 0) return; - const n = Math.max(1, Math.round(predicted[idx] * scale)); - for (let j = 0; j < n; j++) { - acquisitions.push(ph.start + (dur * (j + 0.5)) / n); - } - }); - - if (acquisitions.length > 0 && tickEnd > 0) { - // Layer 1 (background): smoothed luminance gradient - // encoding overall acquisition density along the lane. - // The triangular kernel kills aliasing stripes caused by - // evenly-spaced acquisitions falling into bins. - const BINS = 64; - const binSec = tickEnd / BINS; - const raw = new Array(BINS).fill(0); - for (const t of acquisitions) { - const bin = Math.min(BINS - 1, Math.max(0, Math.floor(t / binSec))); - raw[bin] += 1; - } - const kernel = [1, 2, 3, 4, 5, 4, 3, 2, 1]; - const kSum = kernel.reduce((a, b) => a + b, 0); - const kOff = Math.floor(kernel.length / 2); - const density = new Array(BINS).fill(0); - for (let i = 0; i < BINS; i++) { - let acc = 0, w = 0; - for (let k = 0; k < kernel.length; k++) { - const j = i + k - kOff; - if (j < 0 || j >= BINS) continue; - acc += raw[j] * kernel[k]; - w += kernel[k]; - } - density[i] = w > 0 ? acc / w * (kSum / w) : 0; - } - const maxD = Math.max(...density, 1e-6); - const gradId = `expov-density-${(emb.id || 'e').replace(/\W+/g, '_')}-r${rowTop}`; - const grad = svgEl('linearGradient', { - id: gradId, x1: '0%', x2: '100%', y1: '0%', y2: '0%' - }); - for (let i = 0; i < BINS; i++) { - const intensity = density[i] / maxD; - // Lower ceiling than the heatmap-only version (0.22 vs - // 0.38) because the dots above will carry the per-event - // signal; the band just hints at rate. - const alpha = 0.03 + 0.22 * intensity; - grad.appendChild(svgEl('stop', { - offset: `${(i / (BINS - 1)) * 100}%`, - 'stop-color': '#ffffff', - 'stop-opacity': alpha.toFixed(3), - })); - } - g.appendChild(grad); - const pastW = xForT(tickEnd) - LEFT; - if (pastW > 0) { - g.appendChild(svgEl('rect', { - x: LEFT, y: laneY, - width: pastW, height: LANE_H, - fill: `url(#${gradId})`, - rx: 2, - 'pointer-events': 'none' - })); - } - // Layer 2 (foreground): one soft round dot per acquisition - // along the top edge of the lane — keeps the per-event - // temporal discreteness the heatmap alone hides. - const dotY = laneY + 2; - for (const t of acquisitions) { - const tx = xForT(t); - g.appendChild(svgEl('circle', { - cx: tx, cy: dotY, r: 1.3, - class: 'expov-svg-acq-dot' - })); - } - } - - // ---- Cadence-change markers --------------------------------- - // Where consecutive phases differ in cadence (or mode), drop a - // vertical divider across the lane and a "300→60s · T34" chip - // above so the change is named and time-stamped in the lane. - // Apply the same scale factor used for tick rendering so the - // T# stamps shown on diamonds and cadence chips agree with the - // visible tick density and the authoritative tp_acquired count. - // Otherwise the chip might say "T118" on an embryo where we - // only drew 54 ticks — visually contradictory. - const tpIndexAt = (atS) => { - let count = 0; - for (const ph of emb.phases) { - if (!ph.cadence_s) continue; - if (atS < ph.start) break; - const phEnd = Math.min(atS, ph.end ?? atS); - count += Math.floor((phEnd - ph.start) / ph.cadence_s) + 1; - } - return Math.max(1, Math.round(count * scale)); - }; - // Track placed chip x-positions to avoid stacking chips on top - // of one another (e.g. the burst-balloon already sits there). - const placedChipX = []; - const burstXs = emb.phases - .filter(p => p.mode === 'burst') - .map(p => xForT(p.start)); - const isCollision = (x) => { - const min = 70; // px buffer - if (burstXs.some(bx => Math.abs(bx - x) < min)) return true; - return placedChipX.some(px => Math.abs(px - x) < min); - }; - // Walk phases and detect transitions, but COLLAPSE consecutive - // identical (mode + cadence) phases so a row of redundant phase - // records doesn't generate redundant dividers/chips. - let prevEffective = emb.phases[0]; - for (let i = 1; i < emb.phases.length; i++) { - const curr = emb.phases[i]; - const prev = prevEffective; - const sameCadence = prev.cadence_s === curr.cadence_s; - const sameMode = prev.mode === curr.mode; - if (sameCadence && sameMode) { - continue; // collapse: prevEffective stays the same - } - prevEffective = curr; - if (prev.mode === 'burst' || curr.mode === 'burst') continue; - const cx = xForT(curr.start); - if (cx > xForT(s.now_offset_s)) continue; - // Divider is cheap to keep even on collision; the chip is what - // crowds the space, so we skip just the chip when crowded. - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: laneY, y2: laneBottom, - class: 'expov-svg-cadence-divider' - })); - if (isCollision(cx)) continue; - const tp = tpIndexAt(curr.start); - const prevS = prev.cadence_s ?? '?'; - const currS = curr.cadence_s ?? '?'; - const chipText = `${prevS}→${currS}s · T${tp}`; - const chipW = chipText.length * 5.6 + 10; - const chipY = laneY - 13; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-cadence-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-cadence-chip' - }, chipText)); - placedChipX.push(cx); - } - - // ---- Power-change chips ------------------------------------- - // Same visual language as cadence chips, but parked in a row - // above so the two encodings stack neatly when they happen at - // the same trigger. Each chip names the rule outcome - // ("488 ↓ 5%→3%") and the timepoint it landed at. - const placedPowerChipX = []; - const hist488 = emb.power_history_488 || []; - // Walk the history, collect actual transitions (pairs where pct - // changes), then cluster consecutive close ones so a multi-step - // ramp gets a single annotation. - const transitions = []; - for (let k = 0; k < hist488.length - 1; k++) { - const a = hist488[k]; - const b = hist488[k + 1]; - if (a.pct === b.pct) continue; - transitions.push({ from: a, to: b }); - } - const CLUSTER_S = 60; - const clusters = []; - for (const tr of transitions) { - const last = clusters[clusters.length - 1]; - if (last && tr.to.at - last[last.length - 1].to.at <= CLUSTER_S) { - last.push(tr); - } else { - clusters.push([tr]); - } - } - for (const cluster of clusters) { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - // Anchor the chip at the actual change time, not at any - // trailing anchor record (those can land past `now` and - // get hidden by the past-only guard). - const atS = Math.min(tail.to.at, s.now_offset_s); - const cx = xForT(atS); - const arrow = tail.to.pct < first.from.pct ? '↓' : '↑'; - const chipText = `488 ${arrow} ${first.from.pct}%→${tail.to.pct}% · T${tpIndexAt(atS)}`; - const chipW = chipText.length * 5.6 + 10; - // Sit just above the burst-balloon band (which lives at - // laneY-22..-10) so we stay within this row's vertical - // budget — laneY-40 would have crossed into the row above. - // Burst balloons live at separate x positions on every - // case I've seen, so dropping the burst-collision check - // lets the chip render even when a burst is on the same - // lane elsewhere. - const chipY = laneY - 25; - const crowded = - placedPowerChipX.some(px => Math.abs(px - cx) < 70); - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: chipY + 12, y2: laneY, - class: 'expov-svg-power-chip-stem' - })); - if (crowded) continue; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-power-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-power-chip' - }, chipText)); - placedPowerChipX.push(cx); - } - - // Projected future segment (dashed) past 'now' to a horizon — - // skipped entirely when the embryo has been terminated, since - // there's no future to project. projEndT is hoisted because - // downstream code (stop-icon, dose-exhaust line) anchors to it. - const isTerminated = emb.terminated_at_s != null - && emb.terminated_at_s <= s.now_offset_s; - const projStartT = s.now_offset_s; - let projEndT = isTerminated ? emb.terminated_at_s : s.horizon_s; - let projEndsAtBudget = false; - if (!isTerminated) { - if (emb.projected_end_s) projEndT = Math.min(projEndT, emb.projected_end_s); - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < projEndT) { - projEndT = emb.dose_exhaust_at_s; - projEndsAtBudget = true; - } - const xProjStart = xForT(projStartT); - const xProjEnd = xForT(projEndT); - if (xProjEnd > xProjStart) { - g.appendChild(svgEl('line', { - x1: xProjStart, y1: laneMid, x2: xProjEnd, y2: laneMid, - class: projEndsAtBudget - ? 'expov-svg-projected-bar warn' - : 'expov-svg-projected-bar' - })); - } - } - // Terminated cap: small vertical stop bar at the termination - // point + a "■ DONE · T##" label below the lane so a finished - // embryo doesn't look like it's still acquiring. - if (isTerminated) { - const termX = xForT(emb.terminated_at_s); - g.appendChild(svgEl('line', { - x1: termX, x2: termX, - y1: laneY - 2, y2: laneBottom + 2, - class: 'expov-svg-terminated-bar' - })); - g.appendChild(svgEl('rect', { - x: termX - 2, y: laneY + LANE_H / 2 - 3, - width: 6, height: 6, - class: 'expov-svg-terminated-stop' - })); - const tp = tpIndexAt(emb.terminated_at_s); - const capText = `DONE · T${tp}`; - g.appendChild(svgEl('text', { - x: termX + 6, y: laneBottom + 9, - class: 'expov-svg-terminated-label' - }, capText)); - } - - // Trigger diamonds — placed in the upper half of the lane to avoid - // colliding with the burst balloon above. Each diamond gets a tiny - // T# label below it so the user can see at which timepoint the - // rule fired without hovering. - (emb.trigger_events || []).forEach(te => { - const x = xForT(te.at); - const dy = laneY + 6; - const size = 4; - const trig = s.triggers.find(t => t.id === te.trigger_id); - const label = trig ? trig.label : te.trigger_id; - const dia = svgEl('polygon', { - points: `${x},${dy-size} ${x+size},${dy} ${x},${dy+size} ${x-size},${dy}`, - class: 'expov-svg-trigger-diamond expov-svg-tooltip-target' - }); - const tooltip = svgEl('title'); - tooltip.textContent = `${label}\n${trig?.when_text || ''} → ${trig?.then_text || ''}` + - (te.count ? ` (×${te.count})` : ''); - dia.appendChild(tooltip); - g.appendChild(dia); - g.appendChild(svgEl('text', { - x: x, y: laneBottom + 9, - 'text-anchor': 'middle', - class: 'expov-svg-trigger-tp' - }, `T${tpIndexAt(te.at)}`)); - }); - - // Dose-exhaust warning: ⚠ + time-to-exhaust positioned ABOVE the lane - // so it doesn't overlap with the dashed projected bar - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < s.horizon_s) { - const exhX = xForT(emb.dose_exhaust_at_s); - const remain = emb.dose_exhaust_at_s - s.now_offset_s; - const rh = Math.floor(remain / 3600); - const rm = Math.floor((remain % 3600) / 60); - const exhText = `⚠ budget exhausts in ${rh > 0 ? rh + 'h ' : ''}${rm}m`; - g.appendChild(svgEl('text', { - x: exhX - 4, y: laneY - 5, - 'text-anchor': 'end', - fill: 'var(--accent-orange)', - 'font-size': 10, - 'font-weight': 600, - 'font-family': "'JetBrains Mono', monospace" - }, exhText)); - // Small dotted vertical marker so the user can see WHERE on the lane - g.appendChild(svgEl('line', { - x1: exhX, x2: exhX, y1: laneY, y2: laneY + LANE_H, - stroke: 'var(--accent-orange)', - 'stroke-width': 1.5, - 'stroke-dasharray': '2 2', - opacity: 0.7 - })); - } - // Open-ended ∞ glyph at right edge - if (emb.stop_kind === 'open_ended') { - g.appendChild(svgEl('text', { - x: LEFT + LANE_W + 8, y: laneMid + 5, - class: 'expov-svg-infinity' - }, '∞')); - } else { - g.appendChild(svgEl('text', { - x: xForT(projEndT) + 6, y: laneMid + 4, - class: 'expov-svg-stop-icon expov-svg-stop-hatch' - }, '■')); - } - - // ---- Power strip --------------------------------------------- - // Visual encoding makes "steady" vs "ramping" obvious: - // • Steady segments = thin grey horizontal line - // • Ramping segments = bright cyan line + filled area + dot at each step - // • Step transitions get a small arrow (↓ or ↑) and a delta tag - const powerY = laneY + LANE_H + 6; - const powerH = POWER_H; - const powerYBase = powerY + powerH; - g.appendChild(svgEl('line', { - x1: LEFT, x2: LEFT + LANE_W, y1: powerYBase, y2: powerYBase, - class: 'expov-svg-power-baseline' - })); - const yForPct = (pct) => powerYBase - (pct / 10) * powerH; - - const hist = emb.power_history_488 || []; - if (hist.length > 1) { - // Detect ramp clusters: consecutive change-steps with x-spacing < - // CLUSTER_PX get grouped, annotated once at the cluster end. - const CLUSTER_PX = 20; - const stepEvents = []; // each: {fromIdx, toIdx, isRamp} - for (let k = 0; k < hist.length - 1; k++) { - if (hist[k].pct !== hist[k+1].pct) { - stepEvents.push({ fromIdx: k, toIdx: k+1 }); - } - } - // Group consecutive close steps into clusters - const clusters = []; - stepEvents.forEach(step => { - const last = clusters[clusters.length - 1]; - const stepX = xForT(hist[step.toIdx].at); - if (last) { - const lastX = xForT(hist[last[last.length-1].toIdx].at); - if (Math.abs(stepX - lastX) < CLUSTER_PX) { - last.push(step); - return; - } - } - clusters.push([step]); - }); - - // Draw horizontal "steady" segments + vertical "step" lines for all - // adjacent (hist[k], hist[k+1]) pairs. - for (let k = 0; k < hist.length - 1; k++) { - const x0 = xForT(hist[k].at); - const x1 = xForT(hist[k+1].at); - const y = yForPct(hist[k].pct); - const yNext = yForPct(hist[k+1].pct); - g.appendChild(svgEl('line', { - x1: x0, x2: x1, y1: y, y2: y, - class: 'expov-svg-power-steady' - })); - if (hist[k].pct !== hist[k+1].pct) { - g.appendChild(svgEl('line', { - x1: x1, x2: x1, y1: y, y2: yNext, - class: 'expov-svg-power-step' - })); - g.appendChild(svgEl('circle', { - cx: x1, cy: yNext, r: 2.2, - class: 'expov-svg-power-stepdot' - })); - } - } - // Final tail to lane right edge - const last = hist[hist.length - 1]; - const lastX = xForT(last.at); - const lastY = yForPct(last.pct); - g.appendChild(svgEl('line', { - x1: lastX, x2: LEFT + LANE_W, y1: lastY, y2: lastY, - class: 'expov-svg-power-steady' - })); - - // One annotation per cluster — bracket + "5% → 3%" label - clusters.forEach(cluster => { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - const xStart = xForT(hist[first.fromIdx].at); - const xEnd = xForT(hist[tail.toIdx].at); - const pctStart = hist[first.fromIdx].pct; - const pctEnd = hist[tail.toIdx].pct; - const arrow = pctEnd < pctStart ? '↓' : '↑'; - const yMid = (yForPct(pctStart) + yForPct(pctEnd)) / 2; - // Bracket: small horizontal line above the cluster steps - const bracketY = Math.min(yForPct(pctStart), yForPct(pctEnd)) - 6; - g.appendChild(svgEl('path', { - d: `M ${xStart} ${bracketY+3} L ${xStart} ${bracketY} L ${xEnd+2} ${bracketY} L ${xEnd+2} ${bracketY+3}`, - class: 'expov-svg-power-ramp-bracket' - })); - // Label "488 ↓ 5%→3%" anchored just right of the bracket end - g.appendChild(svgEl('text', { - x: xEnd + 6, y: bracketY + 4, - class: 'expov-svg-power-ramp-label' - }, `${arrow} ${pctStart}%→${pctEnd}%`)); - }); - - // Subtle filled area under the curve — helps read overall level - const areaPts = [`${xForT(hist[0].at)},${powerYBase}`]; - for (let k = 0; k < hist.length; k++) { - const x = xForT(hist[k].at); - const y = yForPct(hist[k].pct); - areaPts.push(`${x},${y}`); - if (k < hist.length - 1) { - const xNext = xForT(hist[k+1].at); - areaPts.push(`${xNext},${y}`); - } - } - areaPts.push(`${LEFT + LANE_W},${yForPct(last.pct)}`); - areaPts.push(`${LEFT + LANE_W},${powerYBase}`); - g.appendChild(svgEl('polygon', { - points: areaPts.join(' '), - class: 'expov-svg-power-area' - })); - } - // Power label with current value — "@" reads as "at this power" - // and avoids confusion with the bullet-separator used elsewhere - g.appendChild(svgEl('text', { - x: LEFT - 8, y: powerY + powerH / 2 + 3, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, `488 @ ${emb.laser_488_pct_now}%`)); - - // ---- Dose gauge ---------------------------------------------- - const doseY = powerYBase + 6; - const doseW = LANE_W; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: doseW, height: DOSE_H, rx: 2, - class: 'expov-svg-dose-track' - })); - const dosePct = emb.dose_used_ms / emb.dose_budget_ms; - const fillCls = dosePct > 0.85 ? 'expov-svg-dose-fill-crit' - : dosePct > 0.60 ? 'expov-svg-dose-fill-warn' - : 'expov-svg-dose-fill-ok'; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: Math.max(1, doseW * dosePct), height: DOSE_H, rx: 2, - class: fillCls - })); - // Dose label (shows 10× hint for calibration role) - const doseLabel = emb.role === 'calibration' ? 'dose (10×)' : 'dose'; - g.appendChild(svgEl('text', { - x: LEFT - 8, y: doseY + DOSE_H - 2, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, doseLabel)); - // Inside the bar: usage figure — "used of budget" is more scannable - // than "x / y s" which reads like a fraction - const usedS = (emb.dose_used_ms / 1000).toFixed(1); - const budgetS = (emb.dose_budget_ms / 1000).toFixed(1); - const doseText = emb.dose_budget_ms > 0 - ? `${usedS}s of ${budgetS}s (${Math.round(dosePct * 100)}%)` - : `${usedS}s used`; - g.appendChild(svgEl('text', { - x: LEFT + 6, y: doseY + DOSE_H - 2, - class: 'expov-svg-dose-text' - }, doseText)); - - return g; - }, // ================================================================= // Operation Spine — data-driven plan renderer (replaces swimlanes) @@ -1261,7 +358,7 @@ const ExperimentOverview = { // Render a single tactic node. _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { - const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued' }; + const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued', paused: 'paused' }; const seq = String(t.seq || idx + 1).padStart(2, '0'); const stateLabel = STATE_LABEL[t.state] || t.state; // First queued tactic gets a "next" badge — COCKED instrument marker. @@ -1300,6 +397,23 @@ const ExperimentOverview = { inner += this._renderOpsKindPlanned(t, ESC); } + // Fix #1: surface flat live.* telemetry keys not covered by structured + // readouts/phases. Render for active (in-progress telemetry) and done + // (completion data such as sustained_hz, mp4_path, last_fired). + // Skip planned — no live data is bound yet. + if (t.state === 'active' || t.state === 'done') { + const SKIP = new Set(['readouts', 'phases', 'target', 'summary', 'desc']); + const flatEntries = Object.entries(live).filter(([k]) => !SKIP.has(k)); + if (flatEntries.length) { + const humanKey = k => k.replace(/_/g, ' '); + const pairs = flatEntries.map(([k, v]) => { + const vStr = v == null ? '—' : String(v); + return `${ESC(humanKey(k))}${ESC(vStr)}`; + }).join(''); + inner += `
    ${pairs}
    `; + } + } + return `
    ${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
    From b98a041fc4560b5366aea39c89e8c02ed00df0aa Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:38:43 +0530 Subject: [PATCH 126/200] chore: untrack SDD scratch doc accidentally committed --- .superpowers/sdd/dv3-whole-branch-fixes.md | 109 --------------------- 1 file changed, 109 deletions(-) delete mode 100644 .superpowers/sdd/dv3-whole-branch-fixes.md diff --git a/.superpowers/sdd/dv3-whole-branch-fixes.md b/.superpowers/sdd/dv3-whole-branch-fixes.md deleted file mode 100644 index 5ec58d80..00000000 --- a/.superpowers/sdd/dv3-whole-branch-fixes.md +++ /dev/null @@ -1,109 +0,0 @@ -# dv3 whole-branch review fixes — backend half - -Agent: backend-fixes agent (this session). -Commit: see footer. -Frontend fixes (experiment-overview.js + css): separate agent, not touched here. - ---- - -## Fix #2 — agent prompt guidance to declare the Operation Plan - -**File:** `gently/harness/prompts/templates.py` - -Added constant `OPERATION_PLAN_GUIDANCE` (~line 225) and injected `{OPERATION_PLAN_GUIDANCE}` -into the system template f-string right after `{REACTIVE_MONITORING_MODES}` (~line 510). -The block tells the agent to call `declare_operation_plan` at experiment planning time -with kind/name/target/scope/rationale + a `live` object (`readouts`/`phases` + flat keys), -and to re-call (patch) on each tactic transition. Also notes that execution tools -(`queue_burst`, `enable_monitoring_mode`, etc.) accept `tactic_id` and flip state automatically. - ---- - -## Fix #3 — wire operation-plan seeding in exit_plan_mode - -**File:** `gently/app/agent.py`, `exit_plan_mode()` (~line 475) - -Hook site confirmed: inside `if active_id:` block, right after the `link_session_campaign` -try/except (line ~464-469). At that point `self.context_store` and `self.session_id` are -guaranteed valid (context_store is used unguarded at `get_plan_item` one line above). - -Added: -```python -try: - from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item - seed_operation_plan_from_plan_item(self.context_store, self.session_id) -except Exception: - logger.exception("operation-plan seeding failed") -``` - ---- - -## Fix #4 — queue_burst phantom-active on soft-reject - -**File:** `gently/app/tools/timelapse_tools.py` (~line 1204) - -`orchestrator.queue_burst` returns `"Burst queued for {embryo_id} (request_id=..., ...)"` on -success and a rejection sentence (embryo not in timelapse / already had burst / already queued) -on soft-reject. Success is reliably distinguishable by `result.startswith("Burst queued for ")`. - -Guard applied: -```python -if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): - cs.transition_tactic(session, tactic_id, "active") -``` - -New test added to `tests/test_tool_tactic_linkage.py`: -`test_queue_burst_soft_reject_does_not_transition` — fake orchestrator returns -`"Embryo 'emb1' already has a queued burst."` → verifies `cs.transitions == []`. - ---- - -## Fix #5 — `paused` valid state - -**File:** `gently/app/tools/operation_plan_tools.py`, line 29 - -`_VALID_STATES` extended: `frozenset({"planned", "active", "done", "paused"})`. -`pause_timelapse` transitions to `"paused"` — this was previously an invalid state -causing a ValueError on validation. - ---- - -## Fix #7 — document `live` in the spec - -**File:** `docs/superpowers/specs/2026-06-28-operations-tab-design.md`, §1 - -Added `live` field to the tactic schema block with `readouts`/`phases` sub-keys and -a documentation paragraph explaining that the agent authors `readouts`/`phases` at -declaration time and the updater merges flat bound keys (`request_id`, `sustained_hz`, -`setpoint`, `locked`, `last_fired`, `new_phase`, …) as telemetry arrives. - ---- - -## Fix #8 — transition_tactic single-loop note - -**File:** `gently/harness/memory/file_store.py`, `transition_tactic` docstring (~line 847) - -Added one-line note: "read-modify-write with no lock; safe because all subscribed event -emissions run on the single asyncio loop thread — revisit if a worker-thread emitter -is ever added." - ---- - -## Test results - -``` -pytest tests/test_operation_plan_tool.py tests/test_tool_tactic_linkage.py \ - tests/test_operation_plan_seeding.py tests/test_operation_plan_store.py -v -65 passed, 0 failed -``` - -Full suite (excluding 3 pre-existing collection errors in test_campaign_coordination.py, -test_gently_store.py, test_text_tool_call_extraction.py): -- Before changes: 32 failed, 673 passed, 19 skipped -- After changes: 32 failed, 674 passed, 19 skipped (+1 new test, 0 new failures) - ---- - -## Commit - -`fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs` From 3a2bda6e28ec17f95de6f0279dfe0f9835d7e4f8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:39:20 +0530 Subject: [PATCH 127/200] chore: gitignore .superpowers SDD scratch (untrack accidentally-committed reports) --- .gitignore | 1 + .superpowers/sdd/dv3-task-7-report.md | 85 --------------------------- 2 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 .superpowers/sdd/dv3-task-7-report.md diff --git a/.gitignore b/.gitignore index f9a50443..34a7c3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ gently/ui/tui/dist/ # Stray local storage: GENTLY_STORAGE_PATH default (D:\Gently3) resolves # literally to ./D:/ under the repo on Linux. Not data we track. /D:/ +.superpowers/ diff --git a/.superpowers/sdd/dv3-task-7-report.md b/.superpowers/sdd/dv3-task-7-report.md deleted file mode 100644 index 033112fe..00000000 --- a/.superpowers/sdd/dv3-task-7-report.md +++ /dev/null @@ -1,85 +0,0 @@ -# Task 7 Report: tactic_id threading + start-edge marking in execution tools - -## Call sites changed - -### `gently/app/orchestration/exclusive.py` - -**`BurstAcquisition.__init__`** (line ~95) -- Added `tactic_id: str | None = None` kwarg; stored as `self._tactic_id`. -- `BURST_START` event data (line ~127): added `"tactic_id": self._tactic_id`. -- `BURST_COMPLETE` event data (line ~229): added `"tactic_id": self._tactic_id`. - -### `gently/app/orchestration/temperature_protocol.py` - -**`run_temp_change_burst_protocol`** signature (line 22) -- Added `tactic_id=None` to the keyword-only params. -- `TEMP_PROTOCOL_STARTED` event data (line ~41): added `"tactic_id": tactic_id`. -- `TEMP_PROTOCOL_COMPLETED` event data (line ~67): added `"tactic_id": tactic_id`. - -### `gently/app/orchestration/timelapse.py` - -**`queue_burst`** method (line ~2056) -- Added `tactic_id: str | None = None` to signature. -- Passes `tactic_id=tactic_id` into `BurstAcquisition(...)` constructor (line ~2098). - -### `gently/app/tools/timelapse_tools.py` - -**`enable_monitoring_mode`** (line ~1025) -- Added `tactic_id: str | None = None`. -- On success: resolves `cs = getattr(agent, "context_store", None)`, `session = getattr(agent, "session_id", None)`; calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). - -**`queue_burst`** tool (line ~1150) -- Added `tactic_id: str | None = None`. -- Passes `tactic_id=tactic_id` to `orchestrator.queue_burst(...)`. -- After successful enqueue: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). - -**`stop_timelapse`** (line ~270) -- Added `tactic_id: str | None = None`. -- After successful stop: calls `cs.transition_tactic(session, tactic_id, "done")` (guarded). - -**`pause_timelapse`** (line ~293) -- Added `tactic_id: str | None = None`. -- After successful pause: calls `cs.transition_tactic(session, tactic_id, "paused")` (guarded). - -### `gently/app/tools/temperature_protocol_tools.py` - -**`run_temp_change_burst_protocol_tool`** (line 40) -- Added `tactic_id: str | None = None`. -- Before `asyncio.create_task`: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). -- Passes `tactic_id=tactic_id` into `_driver(...)` (the `run_temp_change_burst_protocol` coroutine). - -## How the transition + event threading work - -1. **Start edge (tool → store)**: Tool layer resolves the context store via `getattr(agent, "context_store", None)` and session via `getattr(agent, "session_id", None)` — the same pattern as `declare_operation_plan`. If both are present and `tactic_id` is truthy, `transition_tactic` flips the plan tactic to `"active"`. This happens synchronously (before the background driver spawns for the temp protocol, after enqueue for bursts). - -2. **Event threading (orchestration → bus)**: The `tactic_id` is included in `BURST_START`, `BURST_COMPLETE`, `TEMP_PROTOCOL_STARTED`, and `TEMP_PROTOCOL_COMPLETED` event `data` dicts. Task 8's `OperationPlanUpdater` subscribes to these events and can resolve the tactic by `data["tactic_id"]` to flip it to `"done"`. - -3. **Backward compatibility**: All `tactic_id` params default to `None`. Every transition call is guarded: `if tactic_id: cs = ...; if cs and session: cs.transition_tactic(...)`. When `tactic_id` is absent, no store access occurs and the tool behaves identically to before. - -## TDD evidence - -`tests/test_tool_tactic_linkage.py` — 20 tests, all pass. - -Coverage: -- `enable_monitoring_mode` with/without tactic_id (+ no-cs guard) -- `queue_burst` (tool) with/without tactic_id; tactic_id forwarded to orchestrator -- `stop_timelapse` with/without tactic_id -- `pause_timelapse` with/without tactic_id -- `BurstAcquisition._tactic_id` stored; `BURST_START` and `BURST_COMPLETE` events contain `tactic_id` -- `run_temp_change_burst_protocol_tool` with/without tactic_id -- `TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` carry tactic_id (including `None` backward-compat) - -Full suite (excluding 3 pre-existing broken collection targets): **638 passed, 19 skipped** — same count as before Task 7; no new failures. - -## Files changed - -- `gently/app/orchestration/exclusive.py` -- `gently/app/orchestration/temperature_protocol.py` -- `gently/app/orchestration/timelapse.py` -- `gently/app/tools/timelapse_tools.py` -- `gently/app/tools/temperature_protocol_tools.py` -- `tests/test_tool_tactic_linkage.py` (new) - -## Concerns - -None. All changes are purely additive (new optional kwarg, guarded call). The `@tool` decorator wraps sync functions as async coroutines — tests call them with keyword args and `await`; microscope-required tools need a `"client"` in the test context dict (FakeMicroscope provided). From 286e95e99acd9f471ff166acfa50d54ef5ee8f65 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:42:57 +0530 Subject: [PATCH 128/200] =?UTF-8?q?docs(tactics-library):=20spec=20+=20pla?= =?UTF-8?q?n=20(G,=203=20tasks)=20=E2=80=94=20save/reuse=20typed=20tactics?= =?UTF-8?q?=20on=20D's=20substrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-28-tactics-library.md | 35 ++++++++++++ .../2026-06-28-tactics-library-design.md | 57 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-tactics-library.md create mode 100644 docs/superpowers/specs/2026-06-28-tactics-library-design.md diff --git a/docs/superpowers/plans/2026-06-28-tactics-library.md b/docs/superpowers/plans/2026-06-28-tactics-library.md new file mode 100644 index 00000000..a845bfa8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-tactics-library.md @@ -0,0 +1,35 @@ +# Tactics Library (G) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Save / list / apply typed tactics (D's tactic objects) as reusable templates — a near-copy of the plan-template pattern. + +**Architecture:** A tactic-library domain in `FileContextStore` (save/list/get/apply), agent tools, and a read route — mirroring `save_plan_template`/`apply_plan_template`. + +## Global Constraints +- Mirror the plan-template pattern: `_plans.py:551-667` (`save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template`) + `plan_mode/tools/templates.py`. +- A saved tactic is a TEMPLATE (planned form): `{id,name,slug,kind,structure,scope_hint?,description,params?,created_at,created_by}` — NO live state. `apply_tactic` returns a fresh planned tactic (new run id, state="planned", no `live`). +- Store domain `agent/tactic_library/{id}_{slug}.yaml`; fire `CONTEXT_UPDATED`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Tactic-library store domain +**Files:** Modify `gently/harness/memory/file_store.py` (or `_plans.py` if templates live there) — add `save_tactic(tactic, name=None) -> str`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name) -> dict | None`, `apply_tactic(id_or_name) -> dict | None`. Test: `tests/test_tactic_library_store.py`. +- [ ] Confirm the exact plan-template implementation (`_plans.py:551-667`) — the id/slug generation, the YAML write helper, the `_notify`/`CONTEXT_UPDATED` emit — and mirror it for tactics at `agent/tactic_library/`. `apply_tactic` returns a deep copy with a fresh `id` (e.g. `tac_<8hex>`), `state="planned"`, and `live`/run-state stripped. +- [ ] TDD: save a tactic → list shows it → get returns it → apply returns a fresh planned tactic (new id, no live, state=planned); get/apply unknown → None. `pytest tests/test_tactic_library_store.py -v`; `pytest -q` clean. Commit `feat(tactics-library): tactic-library store domain (save/list/get/apply)`. + +### Task 2: Agent tools +**Files:** Create `gently/app/tools/tactic_library_tools.py` (+ register in `tools/__init__`). Test: `tests/test_tactic_library_tools.py`. +- [ ] Mirror `plan_mode/tools/templates.py` (the `@tool` usage + store resolution from context). Tools: `save_tactic(name, tactic, description="")` → `store.save_tactic`; `list_tactics()` → `store.list_tactics`; `apply_tactic(id_or_name)` → `store.apply_tactic` then append the planned tactic to the current Operation Plan (`get_operation_plan(session)` → append → `set_operation_plan`; create a minimal plan if none). Resolve the context store + session from the agent (as `declare_operation_plan` does). Register the module. +- [ ] TDD (fake context store + session): save persists; apply adds a planned tactic to the current Operation Plan; list returns the library; missing store → error. `pytest tests/test_tactic_library_tools.py -v`; `pytest -q` clean. Commit `feat(tactics-library): save/list/apply_tactic agent tools`. + +### Task 3: Route `GET /api/tactic_library` +**Files:** Create `gently/ui/web/routes/tactic_library.py` (+ register in `routes/__init__.py`). Test: `tests/test_tactic_library_route.py`. +- [ ] Mirror `routes/operation_plan.py` — resolve `server.context_store`, return `{tactics: store.list_tactics()}`; empty list when none. Register the router. +- [ ] TDD (TestClient + mock store): returns the library; empty when none. `pytest tests/test_tactic_library_route.py -v`; `pytest -q` clean. Commit `feat(tactics-library): GET /api/tactic_library route`. + +## Self-Review +- Store→Task1; tools→Task2; route→Task3. ✓ +- Open confirmations: the plan-template implementation to mirror (T1), the `@tool` + store/session resolution (T2), the route store handle (T3). +- Type consistency: the saved-tactic dict shape is identical across store (T1), tools (T2), route (T3); `apply_tactic`'s fresh planned tactic matches D's tactic schema. diff --git a/docs/superpowers/specs/2026-06-28-tactics-library-design.md b/docs/superpowers/specs/2026-06-28-tactics-library-design.md new file mode 100644 index 00000000..3bd2ac37 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-tactics-library-design.md @@ -0,0 +1,57 @@ +# Design: Tactics library (sub-project G) + +Status: design decided 2026-06-28. Base branch: off `feature/operations-tab` (D) — G reuses D's +typed tactic objects (the Operation Plan substrate). Mirrors the existing **plan-template** pattern +(`_plans.py:551-667`, `plan_mode/tools/templates.py`). + +## 0. Why G is now small + +D made tactics **typed, agent-authored objects** (kind/structure/scope/live/relations). So a "tactics +library" is just **save / list / apply those typed tactic objects** — a near-copy of how plan +templates are already saved/applied. No new conceptual model; it's the reuse layer over D. + +## 1. The saved tactic + +``` +saved_tactic = { + id, name, slug, kind, # kind ∈ D's tactic kinds + structure, # the tactic's structure (phases/cadence/watch) sans live state + scope_hint?, # default scope (global/embryos) + description, params?, # human description + default params + created_at, created_by # 'agent' | 'human' +} +``` +Stored at `agent/tactic_library/{id}_{slug}.yaml` (a new FileContextStore domain), mirroring the +plan-template store. A saved tactic is a *template* — the planned form of a tactic, with no live +state (no `live`, no `id`-per-run). + +## 2. Architecture (three units, mirroring plan templates) + +### 2.1 Store — `FileContextStore` +Add `save_tactic(tactic, name=None) -> id`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name)`, +`apply_tactic(id_or_name) -> dict` (returns a fresh planned tactic — new run id, `state:"planned"`, +no `live`). Mirror `save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template` +(`_plans.py:551-667`) — same id/slug, YAML, atomic write. Fire `CONTEXT_UPDATED`. + +### 2.2 Tools — `gently/app/tools/tactic_library_tools.py` +- `save_tactic(name, tactic, description="")` — persist a tactic template (the agent passes a tactic + dict, e.g. lifted from the current Operation Plan, or authored fresh). +- `list_tactics()` — the library. +- `apply_tactic(id_or_name)` — instantiate a fresh planned tactic and **add it to the current + Operation Plan** (`get_operation_plan` → append the planned tactic → `set_operation_plan`), so a + saved tactic flows straight into the active plan. Mirror `plan_mode/tools/templates.py`. + +### 2.3 Route — `GET /api/tactic_library` +Mirror `routes/operation_plan.py`; returns `{tactics:[...]}` from `list_tactics()`. For a future +browse UI; the tools are the primary surface for now. + +## 3. Out of scope +- A dedicated library browse/edit UI (the tools + chat suffice for v1; a panel is a follow-on). +- Tactic *sets*/composites (save a whole plan as a named operation template) — a natural extension + once single-tactic save/apply lands; defer. +- Sharing/export across machines. + +## 4. Testing +- Store: save→list→get→apply round-trip; apply returns a fresh planned tactic (new id, no live state). +- Tools: save persists; apply adds a planned tactic to the current Operation Plan; list returns the library. +- Route: returns the library; empty when none. From 126de2c3e1cee64308c984f20fc1cc485a9e8dd4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:48:21 +0530 Subject: [PATCH 129/200] feat(tactics-library): tactic-library store domain (save/list/get/apply) Add save_tactic/list_tactics/get_tactic/apply_tactic to FileContextStore, mirroring the plan-template pattern. Templates live at agent/tactic_library/{id}_{slug}.yaml; apply returns a fresh planned tactic (new id, state=planned, no live). Fires CONTEXT_UPDATED. 33 TDD tests, all passing. --- gently/harness/memory/file_store.py | 88 ++++++++++ tests/test_tactic_library_store.py | 253 ++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 tests/test_tactic_library_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 7a9eba4e..eab691fc 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1793,6 +1793,94 @@ def delete_plan_template(self, template_id: str) -> bool: return True return False + # ================================================================== + # Tactic Library + # ================================================================== + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + """Persist a tactic as a reusable template in agent/tactic_library/. + + Strips runtime state (live, state, original id) and assigns a new + template id + slug. Fires CONTEXT_UPDATED. Returns the template id. + """ + name = name or tactic.get("name") or "unnamed" + slug = self._slugify(name) + tid = self._gen_id() + now = self._now() + + template = { + "id": tid, + "name": name, + "slug": slug, + "kind": tactic.get("kind", "unknown"), + "structure": copy.deepcopy(tactic.get("structure") or {}), + "scope_hint": copy.deepcopy(tactic.get("scope")), + "description": tactic.get("description") or tactic.get("rationale"), + "params": copy.deepcopy(tactic.get("params")), + "relations": copy.deepcopy(tactic.get("relations") or {}), + "live_bind": list(tactic.get("live_bind") or []), + "created_at": now, + "created_by": tactic.get("created_by", "agent"), + } + + path = self.agent_dir / "tactic_library" / f"{tid}_{slug}.yaml" + self._write_yaml(path, template) + self._notify_context_change("tactic_library") + logger.info(f"Saved tactic template '{name}' ({tid})") + return tid + + def list_tactics(self) -> list[dict]: + """List all saved tactic templates, ordered by created_at descending.""" + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return [] + tactics: list[dict] = [] + for f in tl_dir.iterdir(): + if f.suffix in (".yaml", ".yml"): + data = self._read_yaml(f) + if data: + tactics.append(data) + tactics.sort(key=lambda t: t.get("created_at", ""), reverse=True) + return tactics + + def get_tactic(self, id_or_name: str) -> dict | None: + """Return a tactic template by id or name, or None if not found.""" + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return None + for f in tl_dir.iterdir(): + if f.suffix in (".yaml", ".yml"): + data = self._read_yaml(f) + if data and (data.get("id") == id_or_name or data.get("name") == id_or_name): + return data + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + """Return a fresh planned tactic from a saved template. + + The returned dict has a new run id (distinct from the template id), + state="planned", and no live/runtime state. Returns None if the + template is not found. + """ + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + tactic = copy.deepcopy(tmpl) + # Assign a new run id — must differ from the template id + tactic["id"] = self._gen_id() + tactic["state"] = "planned" + # Promote scope_hint back to scope for the tactic + scope_hint = tactic.pop("scope_hint", None) + if scope_hint is not None: + tactic["scope"] = scope_hint + # Strip template-internal metadata + tactic.pop("slug", None) + tactic.pop("created_at", None) + tactic.pop("created_by", None) + # Strip runtime state (should not exist in a template, but guard anyway) + tactic.pop("live", None) + return tactic + # ================================================================== # Plan Snapshots # ================================================================== diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py new file mode 100644 index 00000000..4a92acc5 --- /dev/null +++ b/tests/test_tactic_library_store.py @@ -0,0 +1,253 @@ +""" +FileContextStore — Tactic Library store domain TDD tests. + +Tests: save_tactic / list_tactics / get_tactic / apply_tactic + +Round-trip: save → list → get → apply returns a fresh planned tactic +(new id != template id, state="planned", no "live" key). +get/apply unknown → None. +""" + +import copy +import pytest + + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + "live": {"request_id": "req_abc", "started_at": "2026-06-28T10:00:00"}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# save_tactic +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + def test_returns_string_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + assert isinstance(tid, str) + assert len(tid) > 0 + + def test_id_differs_from_original(self, file_context_store): + """Template id must not equal the source tactic's original id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + assert tid != "t_original" + + def test_name_param_overrides_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic(), name="Custom Name") + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Custom Name" + + def test_name_falls_back_to_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Baseline timelapse" + + def test_live_state_stripped(self, file_context_store): + """The 'live' key must not appear in the saved template.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert "live" not in tmpl + + def test_run_state_not_preserved(self, file_context_store): + """'state' from the source tactic (active) is not stored as the template state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + # Template itself doesn't carry 'state'; it's restored on apply + assert tmpl.get("state") is None or tmpl.get("state") == "planned" + + def test_structure_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["structure"] == {"cadence_s": 120} + + def test_kind_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["kind"] == "standing_timelapse" + + def test_file_created(self, file_context_store): + """A YAML file must exist in tactic_library/ after saving.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tl_dir = file_context_store.agent_dir / "tactic_library" + files = list(tl_dir.iterdir()) + assert any(f.stem.startswith(tid) for f in files) + + def test_fires_context_updated(self, file_context_store): + """save_tactic must fire CONTEXT_UPDATED.""" + from gently.core.event_bus import EventType, on + + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.save_tactic(_fresh_tactic()) + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# list_tactics +# --------------------------------------------------------------------------- + + +class TestListTactics: + def test_empty_when_none_saved(self, file_context_store): + assert file_context_store.list_tactics() == [] + + def test_one_entry_after_save(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert len(result) == 1 + + def test_two_entries_after_two_saves(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="Alpha") + file_context_store.save_tactic(_fresh_tactic(), name="Beta") + result = file_context_store.list_tactics() + assert len(result) == 2 + + def test_entries_contain_id_and_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert result[0]["id"] == tid + assert result[0]["name"] == "Baseline timelapse" + + def test_ordered_newest_first(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="First") + file_context_store.save_tactic(_fresh_tactic(), name="Second") + result = file_context_store.list_tactics() + # Newest should appear first (sorted by created_at desc) + names = [r["name"] for r in result] + assert names[0] == "Second" + + +# --------------------------------------------------------------------------- +# get_tactic +# --------------------------------------------------------------------------- + + +class TestGetTactic: + def test_get_by_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic(tid) + assert result is not None + assert result["id"] == tid + + def test_get_by_name(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("Baseline timelapse") + assert result is not None + assert result["kind"] == "standing_timelapse" + + def test_get_unknown_returns_none(self, file_context_store): + result = file_context_store.get_tactic("no_such_tactic") + assert result is None + + def test_get_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("deadbeef") + assert result is None + + +# --------------------------------------------------------------------------- +# apply_tactic +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + def test_apply_by_id_returns_dict(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert isinstance(applied, dict) + + def test_apply_by_name_returns_dict(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic("Baseline timelapse") + assert isinstance(applied, dict) + + def test_apply_unknown_returns_none(self, file_context_store): + result = file_context_store.apply_tactic("no_such_tactic") + assert result is None + + def test_apply_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.apply_tactic("deadbeef") + assert result is None + + def test_applied_id_differs_from_template_id(self, file_context_store): + """apply_tactic must return a FRESH id, distinct from the template id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != tid + + def test_applied_id_differs_from_source_tactic_id(self, file_context_store): + """Applied id must not equal the original source tactic's id either.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != "t_original" + + def test_applied_state_is_planned(self, file_context_store): + """Returned tactic must have state='planned' regardless of template source state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["state"] == "planned" + + def test_applied_has_no_live_key(self, file_context_store): + """Returned tactic must not carry any 'live' runtime state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "live" not in applied + + def test_applied_structure_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["structure"] == {"cadence_s": 120} + + def test_applied_kind_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["kind"] == "standing_timelapse" + + def test_applied_is_independent_copy(self, file_context_store): + """Mutating the applied tactic must not affect a second apply call.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + first = file_context_store.apply_tactic(tid) + first["structure"]["cadence_s"] = 999 + second = file_context_store.apply_tactic(tid) + assert second["structure"]["cadence_s"] == 120 + + def test_two_applies_have_different_ids(self, file_context_store): + """Each apply call returns a fresh id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + a1 = file_context_store.apply_tactic(tid) + a2 = file_context_store.apply_tactic(tid) + assert a1["id"] != a2["id"] + + def test_applied_no_slug_metadata(self, file_context_store): + """Template-internal 'slug' must not appear in the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "slug" not in applied + + def test_applied_no_created_at(self, file_context_store): + """Template 'created_at' must not bleed into the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "created_at" not in applied From e25798d8c46ed26748e3a42906d13cbad58401ee Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:52:55 +0530 Subject: [PATCH 130/200] feat(tactics-library): save/list/apply_tactic agent tools Three @tool functions (ToolCategory.EXPERIMENT) over FileContextStore's tactic library: save_tactic persists a tactic dict as a reusable template, list_tactics returns a readable id/name/kind summary, apply_tactic instantiates a fresh planned tactic and appends it to the session's Operation Plan (creating a minimal plan if none exists). Store + session resolution mirrors declare_operation_plan. Registered in tools/__init__.py. TDD: 21 tests, all green. --- gently/app/tools/__init__.py | 2 + gently/app/tools/tactic_library_tools.py | 198 ++++++++++++ tests/test_tactic_library_tools.py | 371 +++++++++++++++++++++++ 3 files changed, 571 insertions(+) create mode 100644 gently/app/tools/tactic_library_tools.py create mode 100644 tests/test_tactic_library_tools.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index b3b31d1b..02db9d7c 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -43,6 +43,7 @@ resolution_tools, session_tools, stage_tools, + tactic_library_tools, temperature_protocol_tools, temperature_tools, timelapse_tools, @@ -89,6 +90,7 @@ def register_all_tools(): "operation_plan_tools", "plan_execution_tools", "resolution_tools", + "tactic_library_tools", "session_tools", "stage_tools", "temperature_protocol_tools", diff --git a/gently/app/tools/tactic_library_tools.py b/gently/app/tools/tactic_library_tools.py new file mode 100644 index 00000000..96a3eee5 --- /dev/null +++ b/gently/app/tools/tactic_library_tools.py @@ -0,0 +1,198 @@ +""" +Tactic Library tools — lets the agent save, list, and apply reusable tactics. + +save_tactic — persist a tactic dict from the current Operation Plan as a + reusable template in FileContextStore's tactic_library. +list_tactics — return a readable summary of all saved templates. +apply_tactic — instantiate a template into a fresh planned tactic and append + it to the current session's Operation Plan. + +Store + session resolution mirrors declare_operation_plan in operation_plan_tools.py. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + + +@tool( + name="save_tactic", + description=( + "Save a tactic from the current Operation Plan into the Tactic Library " + "as a reusable template. Pass the tactic dict (e.g. lifted directly from " + "your Operation Plan) and a human-readable name. Runtime state (live, state, " + "original id) is stripped; the template receives a new id. " + "Returns a confirmation with the new template id." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Save my baseline timelapse tactic to the library", + tool_input={ + "name": "Baseline timelapse", + "tactic": { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + "description": "Standard 2-minute cadence baseline timelapse", + }, + ) + ], +) +async def save_tactic( + name: str, + tactic: dict, + description: str = "", + context: dict | None = None, +) -> str: + """Save a tactic as a reusable template in the Tactic Library. + + Parameters + ---------- + name : str + Human-readable name for the template. + tactic : dict + The tactic dict to save (e.g. from the current Operation Plan). + description : str + Optional description; if provided, overrides tactic's rationale field. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot save tactic" + + # Merge description into tactic so save_tactic picks it up via tactic.get("description") + tactic_to_save = dict(tactic) + if description: + tactic_to_save["description"] = description + + tid = cs.save_tactic(tactic_to_save, name=name) + return f"Tactic '{name}' saved to library with id {tid}" + + +@tool( + name="list_tactics", + description=( + "List all tactics saved in the Tactic Library. " + "Returns a summary of each template: id, name, kind." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Show me the tactics in the library", + tool_input={}, + ) + ], +) +async def list_tactics( + context: dict | None = None, +) -> str: + """List all saved tactic templates. + + Parameters + ---------- + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot list tactics" + + tactics = cs.list_tactics() + if not tactics: + return "Tactic Library is empty — no saved templates yet." + + lines = [f"Tactic Library ({len(tactics)} template{'s' if len(tactics) != 1 else ''}):"] + for t in tactics: + lines.append(f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}") + return "\n".join(lines) + + +@tool( + name="apply_tactic", + description=( + "Instantiate a saved tactic template and append it to the current session's " + "Operation Plan. The template is looked up by id or name, a fresh planned tactic " + "is created (new id, state='planned', no runtime state), and it is queued in the " + "plan. If no plan exists for this session, a minimal one is created. " + "Returns an error if the tactic is not found." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Apply the baseline timelapse tactic to the current session", + tool_input={"id_or_name": "Baseline timelapse"}, + ) + ], +) +async def apply_tactic( + id_or_name: str, + context: dict | None = None, +) -> str: + """Instantiate a tactic template and append it to the current Operation Plan. + + Parameters + ---------- + id_or_name : str + The template id or name to look up. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot apply tactic" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot apply tactic to Operation Plan" + + fresh = cs.apply_tactic(id_or_name) + if fresh is None: + return f"Error: No tactic found with id or name '{id_or_name}'" + + # Fetch or create the plan for this session + plan = cs.get_operation_plan(session_id) + if plan is None: + plan = { + "session_id": session_id, + "title": "", + "goal": "", + "tactics": [], + } + + plan.setdefault("tactics", []) + plan["tactics"].append(fresh) + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = f"tactic '{fresh.get('name', id_or_name)}' applied from library" + + cs.set_operation_plan(session_id, plan) + + tactic_name = fresh.get("name", id_or_name) + return ( + f"Tactic '{tactic_name}' (id {fresh['id']}) instantiated from library and " + f"queued as 'planned' in the Operation Plan for session {session_id}" + ) diff --git a/tests/test_tactic_library_tools.py b/tests/test_tactic_library_tools.py new file mode 100644 index 00000000..e1e33dff --- /dev/null +++ b/tests/test_tactic_library_tools.py @@ -0,0 +1,371 @@ +""" +TDD tests for gently/app/tools/tactic_library_tools.py + +Tests: +- save_tactic: calls store.save_tactic and returns a confirmation with the id +- list_tactics: returns readable summary from store.list_tactics +- apply_tactic: fetches template, appends fresh planned tactic to Operation Plan +- apply_tactic unknown id: returns error, does NOT call set_operation_plan +- missing context_store: returns error for all three tools +- tools are importable and registered in the tool registry +""" + +import copy +import pytest + + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# Fake context store +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal context store double for tool tests.""" + + def __init__(self): + self._library: dict[str, dict] = {} # id -> template + self._plans: dict[str, dict] = {} # session_id -> plan + self.save_tactic_calls: list = [] + self.set_operation_plan_calls: list = [] + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + tid = f"tpl_{len(self._library) + 1}" + name = name or tactic.get("name") or "unnamed" + template = { + "id": tid, + "name": name, + "kind": tactic.get("kind", "unknown"), + "description": tactic.get("description") or tactic.get("rationale"), + } + self._library[tid] = template + self.save_tactic_calls.append({"tactic": tactic, "name": name, "returned_id": tid}) + return tid + + def list_tactics(self) -> list[dict]: + return list(self._library.values()) + + def get_tactic(self, id_or_name: str) -> dict | None: + for t in self._library.values(): + if t.get("id") == id_or_name or t.get("name") == id_or_name: + return copy.deepcopy(t) + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + # Return a fresh planned tactic with a new id + fresh = copy.deepcopy(tmpl) + fresh["id"] = f"run_{id_or_name}" + fresh["state"] = "planned" + return fresh + + def get_operation_plan(self, session_id: str) -> dict | None: + return copy.deepcopy(self._plans.get(session_id)) + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = copy.deepcopy(plan) + self.set_operation_plan_calls.append({"session_id": session_id, "plan": copy.deepcopy(plan)}) + + +# --------------------------------------------------------------------------- +# Fake agent +# --------------------------------------------------------------------------- + + +class FakeAgent: + def __init__(self, session_id="sess_001", context_store=None): + self.session_id = session_id + self.context_store = context_store or FakeContextStore() + + +def _make_context(agent): + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Import the tools under test +# --------------------------------------------------------------------------- + + +from gently.app.tools.tactic_library_tools import ( + apply_tactic, + list_tactics, + save_tactic, +) + + +# --------------------------------------------------------------------------- +# save_tactic tests +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + @pytest.mark.asyncio + async def test_calls_store_save_tactic(self): + """save_tactic must call context_store.save_tactic once.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert len(cs.save_tactic_calls) == 1 + + @pytest.mark.asyncio + async def test_passes_name_through(self): + """The name passed to the tool must reach store.save_tactic.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="My custom tactic", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + call = cs.save_tactic_calls[0] + assert call["name"] == "My custom tactic" + + @pytest.mark.asyncio + async def test_returns_confirmation_with_id(self): + """Return value must contain the new template id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + # The id returned by the fake store is "tpl_1" + assert "tpl_1" in result + + @pytest.mark.asyncio + async def test_description_merged_into_tactic(self): + """If description is provided it should be in the tactic passed to the store.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + description="Override description", + context=_make_context(agent), + ) + saved = cs.save_tactic_calls[0]["tactic"] + assert saved.get("description") == "Override description" + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + """If context_store is None, return an error string.""" + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await save_tactic( + name="x", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await save_tactic(name="x", tactic=_fresh_tactic(), context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# list_tactics tests +# --------------------------------------------------------------------------- + + +class TestListTactics: + @pytest.mark.asyncio + async def test_returns_readable_summary(self): + """list_tactics should include id, name, kind in the output.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Pre-populate library + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + result = await list_tactics(context=_make_context(agent)) + assert "tpl_1" in result + assert "Baseline timelapse" in result + assert "standing_timelapse" in result + + @pytest.mark.asyncio + async def test_empty_library_message(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await list_tactics(context=_make_context(agent)) + assert "empty" in result.lower() or "no saved" in result.lower() + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await list_tactics(context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await list_tactics(context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# apply_tactic tests +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + @pytest.mark.asyncio + async def test_appends_tactic_to_existing_plan(self): + """apply_tactic must append the fresh tactic to the session's Operation Plan.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Seed library + existing plan + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "Test plan", + "goal": "Test goal", + "tactics": [], + } + + result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + saved_plan = cs.set_operation_plan_calls[0]["plan"] + assert len(saved_plan["tactics"]) == 1 + assert saved_plan["tactics"][0]["state"] == "planned" + + @pytest.mark.asyncio + async def test_creates_minimal_plan_if_none_exists(self): + """If no plan exists yet, apply_tactic creates a minimal one.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + plan = cs.set_operation_plan_calls[0]["plan"] + assert plan["session_id"] == "sess_001" + assert len(plan["tactics"]) == 1 + + @pytest.mark.asyncio + async def test_appends_not_replaces(self): + """apply_tactic appends; an existing tactic in the plan must remain.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "", + "goal": "", + "tactics": [{"id": "existing_t", "name": "Prior tactic", "kind": "oneshot", "state": "done"}], + } + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + plan = cs.set_operation_plan_calls[0]["plan"] + assert len(plan["tactics"]) == 2 + ids = [t["id"] for t in plan["tactics"]] + assert "existing_t" in ids + + @pytest.mark.asyncio + async def test_unknown_id_returns_error_no_plan_write(self): + """Unknown tactic id → error string, set_operation_plan must NOT be called.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + + result = await apply_tactic(id_or_name="nonexistent", context=_make_context(agent)) + + assert result.startswith("Error:") + assert len(cs.set_operation_plan_calls) == 0 + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_session_id_returns_error(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + agent.session_id = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await apply_tactic(id_or_name="anything", context={}) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_return_value_names_tactic_and_session(self): + """Confirmation message should mention the tactic name and session id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert "Baseline timelapse" in result + assert "sess_001" in result + + +# --------------------------------------------------------------------------- +# Registration smoke test +# --------------------------------------------------------------------------- + + +class TestRegistration: + def test_tools_are_importable(self): + """The three tools must import without error.""" + from gently.app.tools.tactic_library_tools import ( + apply_tactic, + list_tactics, + save_tactic, + ) + + assert callable(save_tactic) + assert callable(list_tactics) + assert callable(apply_tactic) + + def test_module_in_tools_package(self): + """tactic_library_tools must be in tools.__init__ exports.""" + import gently.app.tools as tools_pkg + + assert hasattr(tools_pkg, "tactic_library_tools") + + def test_tools_registered_in_registry(self): + """The three tools must appear by name in the tool registry.""" + import gently.app.tools # noqa: F401 — ensure registration side-effect + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = {entry.name for entry in registry.list_all()} + assert "save_tactic" in names, f"save_tactic not in registry: {names}" + assert "list_tactics" in names, f"list_tactics not in registry: {names}" + assert "apply_tactic" in names, f"apply_tactic not in registry: {names}" From 0dcfdca1eafdc6752a98a3c10574de7d1a475f73 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:55:52 +0530 Subject: [PATCH 131/200] feat(tactics-library): GET /api/tactic_library route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read endpoint that returns the saved tactic library from FileContextStore. Returns {"tactics": []} when the store is absent, list_tactics() raises, or the library is empty — never a 500. --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/tactic_library.py | 27 ++++++++++++ tests/test_tactic_library_route.py | 58 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 gently/ui/web/routes/tactic_library.py create mode 100644 tests/test_tactic_library_route.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 6cd54a7a..f1ead737 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .operation_plan import create_router as create_operation_plan_router +from .tactic_library import create_router as create_tactic_library_router from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router from .notebook import create_router as create_notebook_router @@ -41,6 +42,7 @@ def register_all_routes(server): create_notebook_router, create_temperature_router, create_operation_plan_router, + create_tactic_library_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/tactic_library.py b/gently/ui/web/routes/tactic_library.py new file mode 100644 index 00000000..5ef12804 --- /dev/null +++ b/gently/ui/web/routes/tactic_library.py @@ -0,0 +1,27 @@ +"""Tactic Library route. + +Returns the saved tactic library from FileContextStore (``server.context_store``). +Never raises a 500 — returns an empty list when the store is absent or has no +saved tactics. +""" + +from fastapi import APIRouter + + +def create_router(server) -> APIRouter: + router = APIRouter() + + @router.get("/api/tactic_library") + async def get_tactic_library(): + cs = getattr(server, "context_store", None) + if cs is None: + return {"tactics": []} + try: + tactics = cs.list_tactics() + except Exception: + tactics = [] + if not tactics: + return {"tactics": []} + return {"tactics": tactics} + + return router diff --git a/tests/test_tactic_library_route.py b/tests/test_tactic_library_route.py new file mode 100644 index 00000000..9d3603f6 --- /dev/null +++ b/tests/test_tactic_library_route.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.tactic_library import create_router + +_SAMPLE_TACTICS = [ + {"id": "tac-1", "name": "Monitor drift", "description": "Watch Z drift every 5 min"}, + {"id": "tac-2", "name": "Adjust laser", "description": "Drop 488 nm by 10%"}, +] + + +def _server(tactics, context_store_available=True): + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.list_tactics.return_value = tactics + + srv = MagicMock() + srv.context_store = context_store + return srv, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_tactic_library(): + srv, cs = _server(_SAMPLE_TACTICS) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + body = r.json() + assert body["tactics"] == _SAMPLE_TACTICS + cs.list_tactics.assert_called_once() + + +def test_returns_empty_when_no_tactics(): + srv, cs = _server([]) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_context_store_absent(): + srv, _ = _server(_SAMPLE_TACTICS, context_store_available=False) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_list_tactics_raises(): + srv, cs = _server(_SAMPLE_TACTICS) + cs.list_tactics.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} From 4703f9195f6182c901c5f0c9fc99856765b3ac69 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 20:06:06 +0530 Subject: [PATCH 132/200] fix(tactics-library): scope round-trip test + preserve rationale + deterministic name lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TestScopeRoundTrip: asserts scope survives save→apply, and absent scope stays absent - Preserve rationale as its own key in save_tactic (alongside description); add TestRationalePreservation - Rewrite get_tactic to scan list_tactics() (newest-first) instead of iterdir(); add TestGetTacticDeterministic with name-collision case --- gently/harness/memory/file_store.py | 16 ++++--- tests/test_tactic_library_store.py | 65 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index eab691fc..6eaab365 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1816,6 +1816,7 @@ def save_tactic(self, tactic: dict, name: str | None = None) -> str: "structure": copy.deepcopy(tactic.get("structure") or {}), "scope_hint": copy.deepcopy(tactic.get("scope")), "description": tactic.get("description") or tactic.get("rationale"), + "rationale": tactic.get("rationale"), "params": copy.deepcopy(tactic.get("params")), "relations": copy.deepcopy(tactic.get("relations") or {}), "live_bind": list(tactic.get("live_bind") or []), @@ -1844,15 +1845,18 @@ def list_tactics(self) -> list[dict]: return tactics def get_tactic(self, id_or_name: str) -> dict | None: - """Return a tactic template by id or name, or None if not found.""" + """Return a tactic template by id or name, or None if not found. + + Uses list_tactics() (sorted newest-first by created_at) so that name + lookups are deterministic: on a name collision, the newest entry wins. + id lookups are unique by construction so order does not matter. + """ tl_dir = self.agent_dir / "tactic_library" if not tl_dir.exists(): return None - for f in tl_dir.iterdir(): - if f.suffix in (".yaml", ".yml"): - data = self._read_yaml(f) - if data and (data.get("id") == id_or_name or data.get("name") == id_or_name): - return data + for tactic in self.list_tactics(): + if tactic.get("id") == id_or_name or tactic.get("name") == id_or_name: + return tactic return None def apply_tactic(self, id_or_name: str) -> dict | None: diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py index 4a92acc5..28cd5867 100644 --- a/tests/test_tactic_library_store.py +++ b/tests/test_tactic_library_store.py @@ -251,3 +251,68 @@ def test_applied_no_created_at(self, file_context_store): tid = file_context_store.save_tactic(_fresh_tactic()) applied = file_context_store.apply_tactic(tid) assert "created_at" not in applied + + +# --------------------------------------------------------------------------- +# Fix 1 — scope round-trip +# --------------------------------------------------------------------------- + + +class TestScopeRoundTrip: + def test_applied_scope_matches_source(self, file_context_store): + """scope from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["scope"] == {"mode": "global"} + + def test_applied_no_scope_when_source_has_none(self, file_context_store): + """If source tactic has no scope, applied tactic must not gain a scope key.""" + tactic = _fresh_tactic() + tactic.pop("scope") + tid = file_context_store.save_tactic(tactic) + applied = file_context_store.apply_tactic(tid) + assert "scope" not in applied + + +# --------------------------------------------------------------------------- +# Fix 2 — rationale preservation +# --------------------------------------------------------------------------- + + +class TestRationalePreservation: + def test_rationale_survives_save_apply(self, file_context_store): + """rationale from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied.get("rationale") == "Establish pre-ramp cadence" + + def test_rationale_in_template(self, file_context_store): + """rationale must be stored in the saved template, not only in description.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl.get("rationale") == "Establish pre-ramp cadence" + + +# --------------------------------------------------------------------------- +# Fix 3 — deterministic name lookup (newest-wins on collision) +# --------------------------------------------------------------------------- + + +class TestGetTacticDeterministic: + def test_name_collision_returns_newest(self, file_context_store): + """When two tactics share a name, get_tactic(name) must return the newest.""" + import time + + t1 = _fresh_tactic() + t1["structure"] = {"cadence_s": 60} + file_context_store.save_tactic(t1, name="Shared Name") + # Small pause to guarantee distinct created_at timestamps + time.sleep(0.002) + t2 = _fresh_tactic() + t2["structure"] = {"cadence_s": 120} + file_context_store.save_tactic(t2, name="Shared Name") + + result = file_context_store.get_tactic("Shared Name") + assert result is not None + # Newest entry (cadence_s=120) must win + assert result["structure"]["cadence_s"] == 120 From df7cf8e60b5c7f8de624f831ad1e63fa2a8f0593 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:10:06 +0530 Subject: [PATCH 133/200] docs(d2): embryo roles/strain + multi-embryo observability spec + wrap-up PR plan --- docs/superpowers/PR-PLAN.md | 30 ++++++ ...-28-operations-multiembryo-roles-design.md | 95 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/superpowers/PR-PLAN.md create mode 100644 docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md new file mode 100644 index 00000000..aeb217d3 --- /dev/null +++ b/docs/superpowers/PR-PLAN.md @@ -0,0 +1,30 @@ +# Wrap-up PR plan — the temperature-experiment + Operations goal + +**Principle (user-stated):** capture the breadth of work as **separate PRs**, each a distinct unit of +work with its own identity/ownership and its own tests, **additive on top of PR #58** +(`integration/ux2-all`, the UX-v2 stack), stacked so they **compose into the final product** in order. +Each PR diffs cleanly against its parent in the stack; integrate the chain when rig-verified. + +## The stack (base = PR #58 `integration/ux2-all`) + +| # | Branch | Unit of work | Parent | Tests / review | +|---|--------|--------------|--------|----------------| +| 1 | `feature/temperature-interface` (A) | Temperature persistence (`append/read_temperature_sample`) + sampler service + SVG temperature graph w/ setpoint line | #58 | per-task + whole-branch review; graph Chrome-audited | +| 2 | `feature/manual-mode-live-view` (B1) | Manual-mode imaging: lightsheet brightfield live view (sequence acquisition), illumination control (LED/laser presets), galvo/piezo scan params + **4 acquire-safety fixes** (C1/I1/I2/I3) | A | per-task + whole-branch (opus) review | +| 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes | +| 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited | +| 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests | +| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) | + +Notes: +- Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes) + stay inside their branch — granular enough to capture the work, not so granular it's noise. +- A/B1/C are **kept-as-is pending rig verification** (not merged); D/G/D2 build on top. The stack is + intact but unmerged — PRs can open stacked and merge the chain once verified on the rig. +- "Easy to put together into a final product" = the linear stack already composes; the integration + point is #58 → `development`. + +## At wrap-up +1. Verify each branch's tests pass (the SDD ledgers + whole-branch reviews are the evidence trail). +2. Open the stacked PRs in order (each targets its parent branch), each description capturing its unit. +3. Rebase/integrate the chain onto #58 when rig-verified; #58 → `development` as the final integration. diff --git a/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md b/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md new file mode 100644 index 00000000..c3163fc0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md @@ -0,0 +1,95 @@ +# Design: Embryo roles/strain + multi-embryo Operations observability (sub-project D2) + +Status: design 2026-06-29 (revised after recon + user steering). Extends D (the agent-authored +Operation Plan). Driven by the real temperature-strain run: **multiple embryos with roles**. Branch: +`feature/embryo-roles-observability` (off G / the operations stack). + +## 0. Reuse what exists — gently ALREADY has embryo roles +Recon confirmed a first-class role concept; D2 builds on it, does NOT reinvent it: +- `role: str` on every embryo — `EmbryoInfo["role"]` in `embryo.yaml` (`gently/core/store_types.py:34`, + written by `FileStore.register_embryo` `file_store.py:564/576`) and `EmbryoState.role` + (`gently/harness/state.py:138`, default `"test"`). +- `gently/harness/roles.py:REGISTRY` — the taxonomy. Today: `test` (biological subject, magenta + `#ff66cc`, star, dose ×1), `calibration` (reference, cyan `#00cccc`, diamond, dose ×10, + nuclear-marker pipeline), `unassigned`. Each `EmbryoRole` carries `description, + default_cadence_seconds, detector_name, photodose_budget_multiplier, ui_color, ui_icon, + no_object_consecutive_terminal`. +- Already surfaced in the **Map view** (`marking.js` ROLE_CYCLE/ROLE_COLORS) + `GET + /api/embryos/positions` (`routes/data.py:689`); already read for photodose scaling + (`timelapse.py:426`). The **Embryos strip + Operations do NOT show role** — that is the gap D2 fills. + +## 0a. The load-bearing insight — strain ⊥ use +Role and strain are ORTHOGONAL axes that gently currently conflates (the `calibration` role hardcodes +the nuclear-marker detector): +- **Strain** = the biological sample (e.g. pan-nuclear GFP — GFP in every nucleus; a dopaminergic + reporter). It is what is *labeled* → it determines the **perception/detector**. +- **Use (role)** = what the embryo is FOR: subject (test), calibration, lineaging. Drives + cadence / photodose / adaptive-vs-fixed tactics. +The SAME pan-nuclear strain is used for **lineaging** (C. elegans lineage tracing) OR **calibration** +(microscopist calibrating the lightsheet on the same nuclear structure); and **lineaging** can run on +*non*-pan-nuclear strains. So neither determines the other. Fix: make **strain** the per-embryo field +that drives the detector, and let **role** be purely *use*. + +## 1. Schema changes + +### 1.1 Per-embryo `strain` (NEW, free-form string) +Add `strain: str | None` to `EmbryoInfo` (`store_types.py`) + `EmbryoState` (`state.py`), persisted in +`embryo.yaml` via `register_embryo(..., strain=None)`. Free-form (e.g. `"pan-nuclear GFP"`); coexists +with the existing plan-level `strain`/`genotype`/`reporter` spec overrides +(`memory/file_store.py:1746`). No strain registry this pass. + +### 1.2 Roles refined to USE — extend `roles.REGISTRY` +- Add `lineaging` to the REGISTRY (its own `ui_color`/`ui_icon`/`default_cadence_seconds`/policy). + Distinct *use* from calibration even when it shares the pan-nuclear strain. +- Add a `class` attribute to `EmbryoRole`: `'subject' | 'reference'` — `test`→subject; + `calibration`,`lineaging`→reference; `unassigned`→subject (safe default). Drives the Operations + foregrounding; derived from role, not a new per-embryo field. +- Keep `detector_name` ON the role THIS PASS (staged — see §4). Document that it logically belongs to + strain. + +### 1.3 Role-scoped tactics (on D's Operation Plan) +`tactic.scope` gains `mode:'role'` + `role:` (an existing REGISTRY key: test/calibration/lineaging). +`mode=role` resolves to all roster embryos with that role. Backward compatible (global/embryos unchanged). + +## 2. Observability — Operations roster lens (reads existing role + strain) +A roster lens above the operation spine, reading the LIVE embryos (via the tracker / `EmbryoState.role` +/ `/api/embryos/positions`) + `roles.REGISTRY` metadata (real `ui_color`/`ui_icon` — magenta test, cyan +calibration, lineaging's color) — NOT new colors: +- Split FIRST by role `class`: **Subjects** (foregrounded — adaptive tactics/scenarios) then + **References** (compact — calibration/lineaging holding steady). +- Within each class, group by role (use). Each embryo row: `id · role chip (REGISTRY color/icon) · + strain · cadence-phase chip · current tactic · state`. +- The spine's tactic nodes show scope by role ("→ test · E01,E02,E03"; "→ reference"). +This answers "who's a subject vs a reference, what's each one's strain + use, what's happening to each." + +## 3. Architecture (reuse D + existing role plumbing) +- Strain: field on EmbryoInfo/EmbryoState + register_embryo + the marking tool (`detection_tools.py`) + accepts strain alongside role; `/api/embryos/positions` adds `strain`. +- Roles: extend `roles.py:REGISTRY` (+ `class` on EmbryoRole); reuse `get_role`/`REGISTRY.get`. +- Operations: roster lens in `experiment-overview.js` reading the embryos endpoint + REGISTRY; role-scope + rendering on the spine. Role→embryo_ids resolver is a pure read over the roster. +- Tactic scope: `declare_operation_plan` accepts `scope.role`; seeding can set role-scoped tactics. + +## 4. ⚠️ FULL SEPARATION — TRACKED FUTURE WORK (staged out of this pass) +This pass STAGES the model; the clean end-state (do later, do not lose): +1. **Detector follows strain, not role.** Move `detector_name`/perception selection off `EmbryoRole` + onto strain (a strain→detector mapping / strain registry). `calibration` stops implying the + nuclear-marker pipeline; a pan-nuclear *test* embryo would also get nuclear perception. +2. **Strain registry** (like `roles.py`): strain key → {label, markers, default detector, ui}. Lets + strain drive detector cleanly + gives the UI consistent strain labels/colors. +3. **Role policy audit** once detector is off role: confirm role carries only cadence / photodose / + adaptive-vs-fixed / class, nothing strain-derived. +4. Backfill: existing embryos have role but no strain — a migration/default for strain on legacy data. +These are real work items; they belong in a follow-on PR after D2's staged version lands. + +## 5. Out of scope (D2) +- Per-role analysis/plots (results view, not Operations). +- A manual role/strain editor UI beyond what the Map marking tool already does. +- The full detector→strain decoupling (§4 — explicitly future). + +## 6. Testing +- Strain field round-trips through embryo.yaml (register/get); `/api/embryos/positions` includes it. +- `roles.REGISTRY` has lineaging + `class`; `get_role` returns class; existing roles unchanged. +- Role-scope resolver: role→embryo_ids over a roster; global/embryos unchanged; backward compat. +- Renderer: roster lens (class split → role groups → strain) + role-scoped spine render across fixtures + using REAL REGISTRY colors (node --check + Chrome audit). Roster-less/role-less plan renders as D today. From cc77f0f0d764f2272cacc0098f38a7cba1a3263c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:11:21 +0530 Subject: [PATCH 134/200] =?UTF-8?q?docs(d2):=20implementation=20plan=20(4?= =?UTF-8?q?=20tasks)=20=E2=80=94=20strain=20field,=20roles-as-use,=20role?= =?UTF-8?q?=20scope,=20roster=20lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-29-embryo-roles-observability.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-embryo-roles-observability.md diff --git a/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md new file mode 100644 index 00000000..cdbe76dc --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md @@ -0,0 +1,43 @@ +# Embryo roles/strain + multi-embryo Operations observability (D2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Add a per-embryo `strain` field, refine roles to *use* (add lineaging + a subject/reference `class`), and build the Operations roster lens that reads existing roles + strain. Detector stays on role this pass (full detector→strain separation is tracked future work — spec §4). + +**Architecture:** Reuse gently's existing `role` (`roles.REGISTRY`, `EmbryoState.role`, `embryo.yaml`). Add `strain` alongside role; extend the registry; expose roles to the frontend; render a role+strain roster lens above the operation spine. + +## Global Constraints +- REUSE the existing role concept — do NOT invent a new taxonomy. Extend `gently/harness/roles.py:REGISTRY` (`EmbryoRole` dataclass) and read role via the existing accessors (`EmbryoState.role`, `EmbryoInfo["role"]`, `get_role`/`REGISTRY.get`, `/api/embryos/positions`). +- Strain is a FREE-FORM string per embryo (no registry this pass). Coexists with plan-level strain/genotype overrides. +- Roles render with their REAL `REGISTRY` `ui_color`/`ui_icon` (magenta test, cyan calibration, lineaging's own) — never invented colors. +- `class` ('subject'|'reference') is an attribute ON `EmbryoRole`, derived from role; test/unassigned→subject, calibration/lineaging→reference. +- Backward compatible: embryos without strain → None; plans without role-scope render as D today. +- Detector stays wired to role THIS PASS (spec §4 documents the full separation as future work — do not implement it here). +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Per-embryo `strain` field +**Files:** Modify `gently/core/store_types.py` (`EmbryoInfo` — add `strain: str | None`); `gently/harness/state.py` (`EmbryoState` — add `strain: str | None = None`); `gently/core/file_store.py` (`register_embryo` — accept `strain=None`, write/coalesce it in embryo.yaml like `role`); `gently/ui/web/routes/data.py` (`/api/embryos/positions` ~:689 — add `"strain": emb.get("strain")`). Test: `tests/test_embryo_strain.py`. +- [ ] Confirm how `role` is threaded through `register_embryo` (`file_store.py:507-580`, write at :576, coalesce at :564) and EmbryoState (`state.py:138`); mirror it for `strain`. Confirm the positions endpoint shape (`data.py:661-696`). +- [ ] TDD: register an embryo with `strain="pan-nuclear GFP"` → get_embryo returns it; update coalesces; absent → None; positions endpoint includes strain. `pytest tests/test_embryo_strain.py -v`; `pytest -q` clean. Commit `feat(d2): per-embryo strain field`. + +### Task 2: Roles refined — lineaging + subject/reference class +**Files:** Modify `gently/harness/roles.py` (add `class_: str = "subject"` — or `klass`/`role_class` to avoid the `class` keyword — to `EmbryoRole`; add a `lineaging` entry to `REGISTRY`; set `class_` on each role: test/unassigned→subject, calibration/lineaging→reference; give lineaging its own ui_color/ui_icon/default_cadence/detector kept None or nuclear like calibration). Test: `tests/test_roles_registry.py` (extend if exists). +- [ ] Add the `class_` field (default "subject") to `EmbryoRole`; set it on all REGISTRY entries; add `lineaging` (reference, distinct ui_color e.g. a teal/green, ui_icon, cadence). Keep `detector_name` as-is on each role (staged). Confirm nothing else constructs EmbryoRole positionally in a way the new field breaks. +- [ ] TDD: `REGISTRY["lineaging"].class_=="reference"`; `REGISTRY["test"].class_=="subject"`; `REGISTRY["calibration"].class_=="reference"`; `get_role("lineaging")` works; existing roles/fields unchanged. `pytest tests/test_roles_registry.py -v`; `pytest -q` clean. Commit `feat(d2): roles-as-use — add lineaging + subject/reference class`. + +### Task 3: `/api/roles` route + role-scoped tactic scope +**Files:** Create `gently/ui/web/routes/roles.py` (`GET /api/roles` → `{roles:[{name,description,class_,ui_color,ui_icon,default_cadence_seconds}]}` from `list_roles()`/REGISTRY; register in `routes/__init__.py`). Modify `gently/app/tools/operation_plan_tools.py` (allow `scope.mode=='role'` + `scope.role` in validation — accept a REGISTRY key); add a pure resolver `resolve_scope_embryos(scope, roster_or_embryos) -> list[str]` (in a small module or operation_plan_tools) mapping mode=role→embryo_ids by role. Test: `tests/test_roles_route.py`, `tests/test_role_scope.py`. +- [ ] `/api/roles` mirrors `routes/tactic_library.py` (simple list route, graceful). The resolver maps `{mode:'role',role:'test'}` against a list of embryos-with-roles → the matching ids; global→all, embryos→explicit. Validation accepts mode=role with a valid role key. +- [ ] TDD: route returns the registry incl. lineaging + class_; resolver resolves role→ids, global→all, embryos→explicit, unknown role→[]. `pytest tests/test_roles_route.py tests/test_role_scope.py -v`; `pytest -q` clean. Commit `feat(d2): /api/roles route + role-scoped tactic scope resolver`. + +### Task 4: Operations roster lens (frontend) +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (add a roster lens above the operation spine: fetch `/api/embryos/positions` + `/api/roles`, group embryos by role `class_` (Subjects foregrounded, References compact) then by role, each row `id · role chip (REGISTRY ui_color/ui_icon) · strain · cadence-phase chip · current tactic (from the plan's role-scoped tactics) · state`; render tactic-node scope by role using the resolver/role labels); `gently/ui/web/static/css/experiment.css` (the `.ops-roster*` classes, using the role colors from the API, not hardcoded). Reference: the validated prototype `scratchpad/d2proto/index.html` (regrounded to real role colors). +- [ ] Build the roster lens reading the real endpoints + role metadata (colors from `/api/roles`, not invented); class split → role groups → strain; spine nodes show role-scope ("→ test · E01.."). Backward compat: no embryos/roles → omit the lens, spine renders as D. `node --check`; build/refresh the opsv3 (or d2) Chrome harness with the real files for the controller to audit. Commit `feat(d2): Operations roster lens — embryos by role + strain`. + +## Self-Review +- Strain→T1; roles/class→T2; roles route + role-scope→T3; roster lens→T4. ✓ +- Open confirmations: register_embryo/EmbryoState role threading (T1), EmbryoRole construction sites (T2), the route/resolver pattern (T3), the embryos+roles endpoints + plan cross-reference for current-tactic (T4). +- Type consistency: `strain` str|None across model/store/endpoint; `class_` on EmbryoRole + in /api/roles + read by the renderer; role keys consistent across REGISTRY, scope.role, resolver, renderer. +- Staged: detector stays on role (spec §4 future work referenced, not implemented). From 3ad9afefa3adf743c7052859785561e2fce9ea95 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:14:02 +0530 Subject: [PATCH 135/200] feat(d2): per-embryo strain field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add free-form `strain: str | None` to EmbryoInfo, EmbryoState, register_embryo (create + coalesce-update), and the positions endpoint — threaded exactly like the existing `role` field. Tests in tests/test_embryo_strain.py (6/6 pass). --- gently/core/file_store.py | 7 ++ gently/core/store_types.py | 1 + gently/harness/state.py | 3 + gently/ui/web/routes/data.py | 1 + tests/test_embryo_strain.py | 145 +++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 tests/test_embryo_strain.py diff --git a/gently/core/file_store.py b/gently/core/file_store.py index e7fb2174..d9dafaaa 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -516,6 +516,7 @@ def register_embryo( position_fine: dict | None = None, calibration: dict | None = None, role: str | None = None, + strain: str | None = None, ) -> None: """Register or update an embryo in a session. @@ -523,6 +524,10 @@ def register_embryo( (e.g. ``"test"``, ``"calibration"``, ``"unassigned"``). Persisted in embryo.yaml. None preserves the existing value on update. + ``strain`` is a free-form biological sample descriptor (e.g. + ``"pan-nuclear GFP"``). Orthogonal to role. None preserves the existing + value on update. + Position has two stages: coarse (bottom-camera / manual map placement) and fine (future SPIM-objective alignment). New callers should pass position_coarse / position_fine as dicts of shape {"x": float, "y": @@ -562,6 +567,7 @@ def register_embryo( if calibration is not None else existing.get("calibration"), "role": role if role is not None else existing.get("role", "test"), + "strain": strain if strain is not None else existing.get("strain"), "created_at": existing.get("created_at", _now()), } else: @@ -574,6 +580,7 @@ def register_embryo( "position_fine": position_fine, "calibration": calibration, "role": role if role is not None else "test", + "strain": strain, "created_at": _now(), } diff --git a/gently/core/store_types.py b/gently/core/store_types.py index aa3eeaff..1b7a60fe 100644 --- a/gently/core/store_types.py +++ b/gently/core/store_types.py @@ -32,6 +32,7 @@ class EmbryoInfo(TypedDict, total=False): position_y: float | None calibration: dict | None role: str | None # key into gently.harness.roles.REGISTRY + strain: str | None # free-form biological sample descriptor, e.g. "pan-nuclear GFP" created_at: str diff --git a/gently/harness/state.py b/gently/harness/state.py index 34b7d8f6..5e2af6c4 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -136,6 +136,9 @@ class EmbryoState: # is the safe choice — accidental Calibration→Test only over-protects; # accidental Test→Calibration would burn extra dose on the precious sample. role: str = "test" + # Free-form biological sample descriptor (orthogonal to role). Examples: + # "pan-nuclear GFP", "H2B-mCherry", "wild-type". None = unspecified. + strain: str | None = None # Position — two-stage: coarse (bottom-camera detection or manual map # placement, always present once an embryo exists) and fine (populated diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index cfbeb11c..eed3e5a9 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -687,6 +687,7 @@ async def embryo_positions(): "x": float(x), "y": float(y), "role": emb.get("role", "test"), + "strain": emb.get("strain"), "user_label": emb.get("user_label"), "confidence": emb.get("confidence"), "cadence_phase": emb.get("cadence_phase"), diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py new file mode 100644 index 00000000..474a77ef --- /dev/null +++ b/tests/test_embryo_strain.py @@ -0,0 +1,145 @@ +"""Tests for per-embryo strain field (D2 Task 1). + +Covers: +- register_embryo with strain persists it through get_embryo +- update without strain coalesces (keeps existing value) +- absent strain on create → None +- /api/embryos/positions includes strain +""" + +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +# =========================================================================== +# FileStore tests +# =========================================================================== + + +def test_register_embryo_with_strain(file_store): + """register_embryo with strain; get_embryo returns it.""" + file_store.create_session("s1", name="test") + file_store.register_embryo( + "s1", + "embryo_1", + position_coarse={"x": 10.0, "y": 20.0}, + strain="pan-nuclear GFP", + ) + emb = file_store.get_embryo("s1", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +def test_register_embryo_strain_absent_is_none(file_store): + """register_embryo without strain → strain is None in get_embryo.""" + file_store.create_session("s2", name="test") + file_store.register_embryo("s2", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}) + emb = file_store.get_embryo("s2", "embryo_1") + assert emb is not None + assert emb.get("strain") is None + + +def test_register_embryo_strain_coalesces_on_update(file_store): + """Update without strain keeps the originally set value (coalesce).""" + file_store.create_session("s3", name="test") + # Create with strain + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 5.0, "y": 5.0}, + strain="H2B-mCherry", + ) + # Update with a new position but no strain — existing strain must be kept + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 6.0, "y": 6.0}, + ) + emb = file_store.get_embryo("s3", "embryo_1") + assert emb is not None + assert emb.get("strain") == "H2B-mCherry" + + +def test_register_embryo_strain_can_be_overwritten(file_store): + """Passing a new strain on update replaces the old value.""" + file_store.create_session("s4", name="test") + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="wild-type" + ) + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="pan-nuclear GFP" + ) + emb = file_store.get_embryo("s4", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +# =========================================================================== +# /api/embryos/positions endpoint test +# =========================================================================== + + +def _build_client_with_tracker(embryos_dict): + """Build a FastAPI TestClient whose server has a timelapse_tracker.""" + from gently.ui.web.routes.data import create_router + + app = FastAPI() + server = MagicMock() + + tracker = MagicMock() + tracker.embryos = embryos_dict + server.timelapse_tracker = tracker + + # store.get_embryo_ids needed by /api/embryos (not under test here) + server.store.get_embryo_ids.return_value = [] + + app.include_router(create_router(server)) + return TestClient(app) + + +def test_positions_endpoint_includes_strain(): + """embryo_positions response includes the strain key from tracker.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 10.0, + "stage_y_um": 20.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + "user_label": None, + "confidence": 0.9, + "cadence_phase": None, + "is_complete": False, + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + data = resp.json() + assert len(data["embryos"]) == 1 + point = data["embryos"][0] + assert "strain" in point + assert point["strain"] == "pan-nuclear GFP" + + +def test_positions_endpoint_strain_none_when_absent(): + """embryo_positions returns strain=None when tracker dict has no strain key.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 1.0, + "stage_y_um": 2.0, + "role": "test", + # no "strain" key + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + point = resp.json()["embryos"][0] + assert "strain" in point + assert point["strain"] is None From d14c6de6ce67e067509e0e2482317ac663e79f8f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:19:06 +0530 Subject: [PATCH 136/200] =?UTF-8?q?feat(d2):=20roles-as-use=20=E2=80=94=20?= =?UTF-8?q?add=20lineaging=20+=20subject/reference=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add role_class field ("subject"|"reference") to EmbryoRole frozen dataclass. Set on all existing entries: test/unassigned→subject, calibration→reference. Add lineaging registry entry (role_class=reference, ui_color=#33cc88, icon=triangle, detector=perception) — a distinct use even when the strain overlaps calibration. Detector wiring is staged; strain→detector decoupling deferred to spec §4. 22 new tests in tests/test_roles_registry.py; 0 new failures in full suite. --- gently/harness/roles.py | 28 ++++++ tests/test_roles_registry.py | 174 +++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 tests/test_roles_registry.py diff --git a/gently/harness/roles.py b/gently/harness/roles.py index beaa59a4..68bee551 100644 --- a/gently/harness/roles.py +++ b/gently/harness/roles.py @@ -15,8 +15,16 @@ - ``test``: the biological subject (precious sample). Custom ad-hoc detector. - ``calibration``: reference embryo used for staging/calibration. Absorbs more photodose. Standard perception pipeline. +- ``lineaging``: lineage-tracing reference — tracks nuclei/divisions. Often + a pan-nuclear strain, but the strain is separate from this use. - ``unassigned``: explicit "not yet classified" state. Treated like ``test`` for safety until the user resolves the assignment. + +Role classes +------------ +``role_class`` distinguishes how Operations foregrounds embryos: +- ``"subject"`` — the primary biological subjects of the experiment. +- ``"reference"`` — reference embryos (staging, calibration, lineaging). """ from dataclasses import dataclass @@ -44,6 +52,9 @@ class EmbryoRole: # a short threshold. Test embryos can occasionally pop out and # back, so they get a longer one. no_object_consecutive_terminal: int | None = None + # 'subject' | 'reference' — used by Operations to foreground subjects + # vs references in multi-embryo layouts and scheduling decisions. + role_class: str = "subject" REGISTRY: dict[str, EmbryoRole] = { @@ -56,6 +67,7 @@ class EmbryoRole: ui_color="#888888", ui_icon="circle", no_object_consecutive_terminal=None, + role_class="subject", # safe default: protect like a subject ), "test": EmbryoRole( name="test", @@ -71,6 +83,7 @@ class EmbryoRole: ui_color="#ff66cc", # magenta ui_icon="star", no_object_consecutive_terminal=5, # forgiving — they might drift back + role_class="subject", ), "calibration": EmbryoRole( name="calibration", @@ -85,6 +98,21 @@ class EmbryoRole: ui_color="#00cccc", # cyan ui_icon="diamond", no_object_consecutive_terminal=2, # they don't drift back; gone == gone + role_class="reference", + ), + "lineaging": EmbryoRole( + name="lineaging", + description=( + "Lineage-tracing reference — tracks nuclei/divisions; often a " + "pan-nuclear strain but the strain is separate from this use." + ), + default_cadence_seconds=300.0, + detector_name="perception", # nuclear pipeline, same as calibration + photodose_budget_multiplier=5.0, + ui_color="#33cc88", # teal-green — distinct from cyan (calibration) and magenta (test) + ui_icon="triangle", + no_object_consecutive_terminal=2, # reference embryos don't drift back + role_class="reference", ), } diff --git a/tests/test_roles_registry.py b/tests/test_roles_registry.py new file mode 100644 index 00000000..9fc7181d --- /dev/null +++ b/tests/test_roles_registry.py @@ -0,0 +1,174 @@ +""" +Tests for gently/harness/roles.py — EmbryoRole registry. + +Covers: +- role_class field presence and correct values for all built-in roles +- lineaging role entry: existence, role_class, ui_color, ui_icon +- accessor functions: get_role, is_valid_role, list_roles +- existing roles' other fields unchanged +""" + +import pytest + +from gently.harness.roles import ( + REGISTRY, + EmbryoRole, + DEFAULT_ROLE, + get_role, + is_valid_role, + list_roles, +) + + +# --------------------------------------------------------------------------- +# role_class field — all existing roles +# --------------------------------------------------------------------------- + + +def test_test_role_class_is_subject(): + assert REGISTRY["test"].role_class == "subject" + + +def test_unassigned_role_class_is_subject(): + assert REGISTRY["unassigned"].role_class == "subject" + + +def test_calibration_role_class_is_reference(): + assert REGISTRY["calibration"].role_class == "reference" + + +# --------------------------------------------------------------------------- +# lineaging role — existence and fields +# --------------------------------------------------------------------------- + + +def test_lineaging_role_exists_in_registry(): + assert "lineaging" in REGISTRY + + +def test_lineaging_role_class_is_reference(): + assert REGISTRY["lineaging"].role_class == "reference" + + +def test_lineaging_ui_color(): + # Must be distinct from calibration (#00cccc) and test (#ff66cc) + color = REGISTRY["lineaging"].ui_color + assert color == "#33cc88" + assert color != REGISTRY["calibration"].ui_color + assert color != REGISTRY["test"].ui_color + + +def test_lineaging_ui_icon(): + assert REGISTRY["lineaging"].ui_icon == "triangle" + + +def test_lineaging_description_contains_lineage(): + desc = REGISTRY["lineaging"].description.lower() + assert "lineage" in desc or "nuclei" in desc + + +def test_lineaging_has_cadence(): + assert REGISTRY["lineaging"].default_cadence_seconds > 0 + + +# --------------------------------------------------------------------------- +# Accessor functions +# --------------------------------------------------------------------------- + + +def test_get_role_lineaging_returns_correct_entry(): + role = get_role("lineaging") + assert isinstance(role, EmbryoRole) + assert role.name == "lineaging" + assert role.role_class == "reference" + + +def test_is_valid_role_lineaging_true(): + assert is_valid_role("lineaging") is True + + +def test_is_valid_role_unknown_false(): + assert is_valid_role("nonexistent_role") is False + + +def test_list_roles_includes_lineaging(): + assert "lineaging" in list_roles() + + +def test_list_roles_sorted(): + roles = list_roles() + assert roles == sorted(roles) + + +def test_get_role_unknown_raises_key_error(): + with pytest.raises(KeyError, match="Unknown embryo role"): + get_role("not_a_role") + + +# --------------------------------------------------------------------------- +# Existing roles — other fields unchanged +# --------------------------------------------------------------------------- + + +def test_test_role_fields_unchanged(): + role = REGISTRY["test"] + assert role.name == "test" + assert role.ui_color == "#ff66cc" + assert role.ui_icon == "star" + assert role.photodose_budget_multiplier == 1.0 + assert role.detector_name == "dopaminergic_signal" + assert role.no_object_consecutive_terminal == 5 + + +def test_calibration_role_fields_unchanged(): + role = REGISTRY["calibration"] + assert role.name == "calibration" + assert role.ui_color == "#00cccc" + assert role.ui_icon == "diamond" + assert role.photodose_budget_multiplier == 10.0 + assert role.detector_name == "perception" + assert role.no_object_consecutive_terminal == 2 + + +def test_unassigned_role_fields_unchanged(): + role = REGISTRY["unassigned"] + assert role.name == "unassigned" + assert role.ui_color == "#888888" + assert role.ui_icon == "circle" + assert role.detector_name is None + assert role.no_object_consecutive_terminal is None + + +# --------------------------------------------------------------------------- +# DEFAULT_ROLE sanity +# --------------------------------------------------------------------------- + + +def test_default_role_is_in_registry(): + assert DEFAULT_ROLE in REGISTRY + + +def test_default_role_is_test(): + assert DEFAULT_ROLE == "test" + + +# --------------------------------------------------------------------------- +# EmbryoRole is frozen (immutable) +# --------------------------------------------------------------------------- + + +def test_embryo_role_is_frozen(): + role = REGISTRY["test"] + with pytest.raises(Exception): + role.name = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# role_class field has a safe default for keyword construction +# --------------------------------------------------------------------------- + + +def test_embryo_role_default_role_class(): + """New EmbryoRole without role_class defaults to 'subject'.""" + role = EmbryoRole(name="dummy", description="test only") + assert role.role_class == "subject" From f48b2068da42d0785dd11f79aedbb4db36e85b5c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:19:09 +0530 Subject: [PATCH 137/200] docs(sdd): d2-task-2 report --- .superpowers/sdd/d2-task-2-report.md | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .superpowers/sdd/d2-task-2-report.md diff --git a/.superpowers/sdd/d2-task-2-report.md b/.superpowers/sdd/d2-task-2-report.md new file mode 100644 index 00000000..5a9a33c4 --- /dev/null +++ b/.superpowers/sdd/d2-task-2-report.md @@ -0,0 +1,106 @@ +# D2 Task 2 Report — roles-as-use: lineaging + subject/reference class + +## What was done + +### 1. `role_class` field added to `EmbryoRole` + +Added `role_class: str = "subject"` as the last field in the frozen dataclass +(`gently/harness/roles.py`, after `no_object_consecutive_terminal`). Default is +`"subject"` so any existing keyword-constructed `EmbryoRole` is safe without +change. Docstring in the module header updated to document the two values +(`"subject"` | `"reference"`) and their intent. + +### 2. `role_class` set on all three existing REGISTRY entries + +| Role | role_class | Rationale | +|-------------|-------------|------------------------------------------------| +| `test` | `"subject"` | The biological subject — the precious sample. | +| `unassigned`| `"subject"` | Safe default: protect like a subject until resolved. | +| `calibration`| `"reference"` | Reference embryo for staging/calibration. | + +### 3. `lineaging` entry added to REGISTRY + +```python +"lineaging": EmbryoRole( + name="lineaging", + description=( + "Lineage-tracing reference — tracks nuclei/divisions; often a " + "pan-nuclear strain but the strain is separate from this use." + ), + default_cadence_seconds=300.0, + detector_name="perception", # nuclear pipeline, same as calibration + photodose_budget_multiplier=5.0, + ui_color="#33cc88", # teal-green — distinct from cyan/magenta + ui_icon="triangle", + no_object_consecutive_terminal=2, # reference: gone == gone + role_class="reference", +) +``` + +Color distinctness verified: `#33cc88` (teal-green) ≠ `#00cccc` (calibration +cyan) ≠ `#ff66cc` (test magenta). + +### 4. EmbryoRole construction-site check + +``` +grep -rn "EmbryoRole(" gently/ +``` + +Result: only `gently/harness/roles.py` — the three (now four) REGISTRY entries, +all using keyword arguments. No positional construction anywhere. The new field's +default makes it safe for any future keyword construction too. + +### 5. What was NOT touched (detector — staged) + +- No changes to detector/perception wiring, session logic, cadence policy, + or any consumer of `EmbryoRole` fields. +- `detector_name="perception"` on `lineaging` mirrors `calibration` — the + staged approach defers any strain→detector decoupling to spec §4 future work. +- `DEFAULT_ROLE`, `get_role`, `is_valid_role`, `list_roles` — all unchanged + (they work generically over REGISTRY). + +## TDD evidence + +File: `tests/test_roles_registry.py` (created, 22 tests) + +``` +tests/test_roles_registry.py::test_test_role_class_is_subject PASSED +tests/test_roles_registry.py::test_unassigned_role_class_is_subject PASSED +tests/test_roles_registry.py::test_calibration_role_class_is_reference PASSED +tests/test_roles_registry.py::test_lineaging_role_exists_in_registry PASSED +tests/test_roles_registry.py::test_lineaging_role_class_is_reference PASSED +tests/test_roles_registry.py::test_lineaging_ui_color PASSED +tests/test_roles_registry.py::test_lineaging_ui_icon PASSED +tests/test_roles_registry.py::test_lineaging_description_contains_lineage PASSED +tests/test_roles_registry.py::test_lineaging_has_cadence PASSED +tests/test_roles_registry.py::test_get_role_lineaging_returns_correct_entry PASSED +tests/test_roles_registry.py::test_is_valid_role_lineaging_true PASSED +tests/test_roles_registry.py::test_is_valid_role_unknown_false PASSED +tests/test_roles_registry.py::test_list_roles_includes_lineaging PASSED +tests/test_roles_registry.py::test_list_roles_sorted PASSED +tests/test_roles_registry.py::test_get_role_unknown_raises_key_error PASSED +tests/test_roles_registry.py::test_test_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_calibration_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_unassigned_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_default_role_is_in_registry PASSED +tests/test_roles_registry.py::test_default_role_is_test PASSED +tests/test_roles_registry.py::test_embryo_role_is_frozen PASSED +tests/test_roles_registry.py::test_embryo_role_default_role_class PASSED +22 passed in 0.12s +``` + +Full suite (excluding 3 pre-existing collection errors): +- Before task-2 changes: 41 pre-existing failures + 4 from this test file against old code = 45 total +- After task-2 changes: 32 failures, 765 passed — all 32 failures pre-existing (test_eval, test_mesh_service, test_prompt_manager and others), zero new failures introduced. + +## Files changed + +- `gently/harness/roles.py` — `role_class` field on dataclass + all 4 registry entries +- `tests/test_roles_registry.py` — new, 22 tests + +## Concerns + +None. The change is additive and isolated to `roles.py`. The frozen dataclass +with a defaulted field is backward-compatible. The `lineaging` detector choice +(`"perception"`) is intentionally the same as calibration — decoupling +detector-from-use is explicitly deferred to spec §4. From a697a681fd59a4478fa691482e111206335ce29f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:24:05 +0530 Subject: [PATCH 138/200] feat(d2): /api/roles route + role-scoped tactic scope resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/roles returns REGISTRY as {roles:[{name,description,role_class, ui_color,ui_icon,default_cadence_seconds}]}; registered in routes/__init__.py - resolve_scope_embryos() in gently/app/orchestration/role_scope.py maps scope.mode in {global,embryos,role} → list[embryo_id]; unknown → [] - _validate_tactics already passes scope through unmodified; role-scoped tactics (mode='role', role in REGISTRY) confirmed to pass declare_operation_plan - Embryo id key confirmed as 'embryo_id' (from /api/embryos/positions) - 22 new tests: 9 route + 13 resolver/validation; 0 new failures in suite --- gently/app/orchestration/role_scope.py | 60 ++++++++ gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/roles.py | 30 ++++ tests/test_role_scope.py | 198 +++++++++++++++++++++++++ tests/test_roles_route.py | 78 ++++++++++ 5 files changed, 368 insertions(+) create mode 100644 gently/app/orchestration/role_scope.py create mode 100644 gently/ui/web/routes/roles.py create mode 100644 tests/test_role_scope.py create mode 100644 tests/test_roles_route.py diff --git a/gently/app/orchestration/role_scope.py b/gently/app/orchestration/role_scope.py new file mode 100644 index 00000000..b36bd89e --- /dev/null +++ b/gently/app/orchestration/role_scope.py @@ -0,0 +1,60 @@ +"""Role-scoped tactic target resolver. + +``resolve_scope_embryos`` maps a tactic ``scope`` dict + a roster of embryo +dicts to a list of embryo IDs that the tactic should operate on. + +Scope modes +----------- +``global`` + All embryo IDs in the roster. +``embryos`` + Explicit list from ``scope['embryo_ids']``. +``role`` + IDs of embryos whose ``role`` field matches ``scope['role']``. +missing/unknown + Empty list (safe default — never errors). + +Embryo dict shape +----------------- +The resolver expects the shape produced by ``/api/embryos/positions``: +each dict must have an ``'embryo_id'`` key (not just ``'id'``). +""" + + +def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]: + """Return the embryo IDs that match *scope* from *embryos*. + + Parameters + ---------- + scope: + Tactic scope dict, e.g. ``{"mode": "role", "role": "test"}``. + ``None`` is treated as an unknown scope → returns ``[]``. + embryos: + List of embryo dicts, each with at minimum ``embryo_id`` and ``role`` + keys. Any dict lacking ``embryo_id`` is silently skipped. + + Returns + ------- + list[str] + Matched embryo IDs. Never raises. + """ + if not scope: + return [] + + mode = scope.get("mode") + + if mode == "global": + return [e["embryo_id"] for e in embryos if "embryo_id" in e] + + if mode == "embryos": + return list(scope.get("embryo_ids") or []) + + if mode == "role": + target_role = scope.get("role") + return [ + e["embryo_id"] + for e in embryos + if "embryo_id" in e and e.get("role") == target_role + ] + + return [] diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index f1ead737..caebd0ec 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .operation_plan import create_router as create_operation_plan_router +from .roles import create_router as create_roles_router from .tactic_library import create_router as create_tactic_library_router from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router @@ -42,6 +43,7 @@ def register_all_routes(server): create_notebook_router, create_temperature_router, create_operation_plan_router, + create_roles_router, create_tactic_library_router, ): router = factory(server) diff --git a/gently/ui/web/routes/roles.py b/gently/ui/web/routes/roles.py new file mode 100644 index 00000000..057a97e4 --- /dev/null +++ b/gently/ui/web/routes/roles.py @@ -0,0 +1,30 @@ +"""Embryo Role Registry route. + +Returns the static REGISTRY of embryo roles from ``gently.harness.roles``. +Never raises a 500 — the registry is global and read-only. +""" + +from fastapi import APIRouter + +from gently.harness.roles import REGISTRY + + +def create_router(server) -> APIRouter: # noqa: ARG001 (server not needed; registry is global) + router = APIRouter() + + @router.get("/api/roles") + async def get_roles(): + roles = [ + { + "name": role.name, + "description": role.description, + "role_class": role.role_class, + "ui_color": role.ui_color, + "ui_icon": role.ui_icon, + "default_cadence_seconds": role.default_cadence_seconds, + } + for role in REGISTRY.values() + ] + return {"roles": roles} + + return router diff --git a/tests/test_role_scope.py b/tests/test_role_scope.py new file mode 100644 index 00000000..53b8ee1a --- /dev/null +++ b/tests/test_role_scope.py @@ -0,0 +1,198 @@ +"""Tests for resolve_scope_embryos resolver + role-scoped tactic validation (Task 3, D2). + +Resolver lives at gently.app.orchestration.role_scope.resolve_scope_embryos. + +Contracts: +- mode='global' → all embryo ids +- mode='embryos' + embryo_ids → exactly those ids +- mode='role' + role='test' → ids of embryos with that role +- mode='role' with unknown role → [] (not an error) +- missing/unknown mode → [] +- embryo dicts use 'embryo_id' key (from /api/embryos/positions) + +Scope validation in declare_operation_plan: +- A tactic with scope.mode='role' and a valid REGISTRY key passes validation. +""" + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_EMBRYOS = [ + {"embryo_id": "e1", "role": "test"}, + {"embryo_id": "e2", "role": "test"}, + {"embryo_id": "e3", "role": "calibration"}, + {"embryo_id": "e4", "role": "lineaging"}, + {"embryo_id": "e5", "role": "unassigned"}, +] + + +# --------------------------------------------------------------------------- +# Resolver tests +# --------------------------------------------------------------------------- + + +def test_global_scope_returns_all_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, _EMBRYOS) + assert set(ids) == {"e1", "e2", "e3", "e4", "e5"} + + +def test_embryos_scope_returns_explicit_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "embryos", "embryo_ids": ["e1", "e3"]}, _EMBRYOS) + assert ids == ["e1", "e3"] + + +def test_role_scope_filters_by_role(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "test"}, _EMBRYOS) + assert set(ids) == {"e1", "e2"} + + +def test_role_scope_calibration(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "calibration"}, _EMBRYOS) + assert ids == ["e3"] + + +def test_role_scope_lineaging(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "lineaging"}, _EMBRYOS) + assert ids == ["e4"] + + +def test_role_scope_unknown_role_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "nonexistent_role"}, _EMBRYOS) + assert ids == [] + + +def test_unknown_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "burst_only"}, _EMBRYOS) + assert ids == [] + + +def test_missing_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({}, _EMBRYOS) + assert ids == [] + + +def test_none_scope_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos(None, _EMBRYOS) + assert ids == [] + + +def test_empty_embryo_list(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, []) + assert ids == [] + + +def test_embryo_id_key_used_not_id(): + """Resolver must read 'embryo_id', not 'id'.""" + from gently.app.orchestration.role_scope import resolve_scope_embryos + + # If resolver accidentally uses 'id', this returns [] + embryos = [{"embryo_id": "eX", "role": "test"}] + ids = resolve_scope_embryos({"mode": "global"}, embryos) + assert "eX" in ids + + +# --------------------------------------------------------------------------- +# declare_operation_plan: role-scoped tactic passes validation +# --------------------------------------------------------------------------- + + +class _FakeContextStore: + def __init__(self): + self._plans = {} + + def set_operation_plan(self, sid, plan): + self._plans[sid] = plan + + def get_operation_plan(self, sid): + return self._plans.get(sid) + + +class _FakeAgent: + def __init__(self): + self.context_store = _FakeContextStore() + self.session_id = "sess_scope_test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_passes_declare_validation(): + """A tactic with scope.mode='role' and a valid role key is accepted by declare_operation_plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + role_scoped_tactic = { + "id": "t-role", + "name": "Test-embryo timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "role", "role": "test"}, + "rationale": "Only image the test embryos", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title="Role-scoped plan", + goal="Image test embryos only", + tactics=[role_scoped_tactic], + updated_reason="role scope test", + context=ctx, + ) + + assert "Error" not in result, f"Unexpected error: {result}" + stored = ctx["agent"].context_store.get_operation_plan("sess_scope_test") + assert stored is not None + tactic = stored["tactics"][0] + assert tactic["scope"]["mode"] == "role" + assert tactic["scope"]["role"] == "test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_all_registry_roles(): + """All four REGISTRY role keys are accepted as valid scope.role values.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + for role_name in ("test", "calibration", "lineaging", "unassigned"): + tactic = { + "id": f"t-{role_name}", + "name": f"{role_name} monitor", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "role", "role": role_name}, + "rationale": f"Target {role_name} embryos", + "structure": {"cadence_s": 300}, + "live_bind": [], + "relations": {}, + } + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title=f"Plan for {role_name}", + goal="Role-specific imaging", + tactics=[tactic], + context=ctx, + ) + assert "Error" not in result, f"Role {role_name!r} was rejected: {result}" diff --git a/tests/test_roles_route.py b/tests/test_roles_route.py new file mode 100644 index 00000000..c710673f --- /dev/null +++ b/tests/test_roles_route.py @@ -0,0 +1,78 @@ +"""Tests for GET /api/roles — embryo role registry endpoint (Task 3, D2). + +Verifies: +- Returns all roles in the registry including lineaging and role_class. +- Each entry has the expected fields. +- Never raises a 500 (graceful). +""" +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.roles import create_router + + +def _client(): + """Build a minimal TestClient with only the roles router.""" + server = object() # roles route never uses server attributes + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_roles_returns_200(): + r = _client().get("/api/roles") + assert r.status_code == 200 + + +def test_roles_body_has_roles_key(): + body = _client().get("/api/roles").json() + assert "roles" in body + assert isinstance(body["roles"], list) + + +def test_roles_includes_all_registry_entries(): + """All four built-in roles must be present.""" + roles = _client().get("/api/roles").json()["roles"] + names = {r["name"] for r in roles} + assert names == {"unassigned", "test", "calibration", "lineaging"} + + +def test_roles_include_lineaging(): + roles = _client().get("/api/roles").json()["roles"] + lineaging = next(r for r in roles if r["name"] == "lineaging") + assert lineaging["role_class"] == "reference" + assert lineaging["ui_color"] == "#33cc88" + assert lineaging["ui_icon"] == "triangle" + + +def test_roles_entry_has_all_required_fields(): + """Every role entry exposes the required fields.""" + required = {"name", "description", "role_class", "ui_color", "ui_icon", "default_cadence_seconds"} + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + missing = required - role.keys() + assert not missing, f"Role {role.get('name')!r} missing fields: {missing}" + + +def test_test_role_class_is_subject(): + roles = _client().get("/api/roles").json()["roles"] + test_role = next(r for r in roles if r["name"] == "test") + assert test_role["role_class"] == "subject" + + +def test_calibration_role_class_is_reference(): + roles = _client().get("/api/roles").json()["roles"] + cal = next(r for r in roles if r["name"] == "calibration") + assert cal["role_class"] == "reference" + + +def test_cadence_seconds_is_numeric(): + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + assert isinstance(role["default_cadence_seconds"], (int, float)) + + +def test_never_500(): + """The route must never raise a 500 regardless of server state.""" + r = _client().get("/api/roles") + assert r.status_code != 500 From b1a47ced30317da71ac2a5d051856236963c624e Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:34:27 +0530 Subject: [PATCH 139/200] =?UTF-8?q?feat(d2):=20Operations=20roster=20lens?= =?UTF-8?q?=20=E2=80=94=20embryos=20by=20role=20+=20strain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a population roster lens above the tactic spine in the Operations view. Reads /api/embryos/positions (role, strain, cadence_phase) and /api/roles (ui_color, ui_icon, role_class) fetched in parallel at init and on each debounced refresh. Roster structure: - SUBJECTS section (role_class=subject, foregrounded, animated live dot) - REFERENCES section (role_class=reference, compact/muted rows) - Within each class: role groups with left-edge in real REGISTRY ui_color - Each embryo row: id · role chip · strain · cadence-phase chip · current tactic (cross-referenced from plan scope) · state Tactic spine nodes gain role-scope badges when roster context is present: → test · E01, E02, E03 (role mode, magenta from API) → all embryos (global, muted grey) → E01, E02 (explicit embryos, muted grey) Role colors come from /api/roles ui_color at runtime via inline styles (_hexToRgba helper); nothing role-specific is hardcoded in experiment.css. Backward compat: if either endpoint is empty/absent, rosterCtx = null, the lens is omitted, and the spine renders exactly as before D2. The fifth rosterCtx argument on _renderOpsTactic defaults to null so all existing callers (scenario mode etc.) are unaffected. node --check: PASS --- gently/ui/web/static/css/experiment.css | 267 ++++++++++++++++++ .../ui/web/static/js/experiment-overview.js | 253 ++++++++++++++++- 2 files changed, 514 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 1d48cef5..a9f89bd7 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -1134,3 +1134,270 @@ border-left: 3px solid var(--text-muted, #8b949e); opacity: 0.8; } + +/* ============================================================ + D2 — Roster Lens (.ops-roster* classes) + Role accent colors are injected at runtime via inline style + from /api/roles — never hardcoded here so the real REGISTRY + palette (magenta test #ff66cc, cyan calibration #00cccc, + teal lineaging #33cc88) drives the rendering. + ============================================================ */ + +/* Sub-label between roster block and spine */ +.ops-section-label { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.65; + margin: 0 0 10px; +} + +/* Roster container */ +.ops-roster { + margin-bottom: 28px; + border: 1px solid var(--border, #30363d); + border-radius: 12px; + overflow: hidden; +} + +.ops-roster-head { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + background: var(--bg-hover, #21262d); + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-roster-title { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.ops-roster-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} + +/* Class section (SUBJECTS / REFERENCES) */ +.ops-class-section { + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-section:last-child { + border-bottom: none; +} + +.ops-class-header { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px; + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-header.subject { background: rgba(245, 166, 35, 0.04); } +.ops-class-header.reference { background: rgba(139, 148, 158, 0.06); } + +.ops-class-label { + font-family: var(--ops-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; +} + +.ops-class-header.subject .ops-class-label { color: var(--ops-active); } +.ops-class-header.reference .ops-class-label { color: var(--text-muted); } + +.ops-class-desc { + font-family: var(--ops-mono); + font-size: 9.5px; + color: var(--text-muted); + opacity: 0.65; +} + +/* Pulsing live dot in the SUBJECTS header */ +.ops-class-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ops-active); + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.2); + animation: ops-roster-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes ops-roster-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Role group within a class section */ +.ops-role-group { + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.ops-role-group:last-child { + border-bottom: none; +} + +/* Role group header — left edge color injected via inline style */ +.ops-role-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-left: 3px solid transparent; +} + +.ops-role-name { + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + /* color injected via inline style */ +} + +.ops-role-sep { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-ids { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: 2px; +} + +/* Embryo rows — subjects full size, references compact */ +.ops-roster-embryo { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px 6px 20px; + border-bottom: 1px solid rgba(48, 54, 61, 0.4); + font-family: var(--ops-mono); + font-size: 11.5px; + transition: background 0.1s; +} + +.ops-roster-embryo:last-child { border-bottom: none; } +.ops-roster-embryo:hover { background: var(--bg-hover, #21262d); } + +.ops-roster-embryo.compact { + padding: 4px 16px 4px 20px; + font-size: 10.5px; + opacity: 0.8; +} + +/* Embryo row fields */ +.ops-rem-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +/* Role chip — background/color/border via inline style from /api/roles */ +.ops-rem-role-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 9px; + font-size: 9.5px; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.04em; + border: 1px solid transparent; + white-space: nowrap; + flex-shrink: 0; +} + +.ops-rem-strain { + color: var(--text-muted); + font-size: 10.5px; + opacity: 0.8; + flex: 0 0 auto; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Cadence phase chip — same visual system as .ops-cadence-phase */ +.ops-rem-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + flex-shrink: 0; +} + +.ops-rem-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-rem-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-rem-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-rem-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-rem-tactic { + color: var(--text); + font-size: 11px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-rem-state { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + flex-shrink: 0; +} + +.ops-rem-state.active { color: var(--ops-active); } +.ops-rem-state.done { color: var(--ops-done); } + +/* Scope badge on tactic spine nodes */ +.ops-scope-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 9px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 600; + border: 1px solid transparent; + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; +} + +/* Global / explicit-embryos scope — static muted style */ +.ops-scope-global { + background: rgba(139, 148, 158, 0.15); + color: var(--text-muted); + border-color: rgba(139, 148, 158, 0.3); +} +/* Role-scoped badges use inline style (color/bg/border from /api/roles) */ diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 6ba9b109..f480ea1a 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -19,6 +19,8 @@ const ExperimentOverview = { _subscribed: false, // guard: prevents double-registration across tab re-clicks _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed + _rosterEmbyros: [], // embryos from /api/embryos/positions (D2 roster lens) + _rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens) async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); @@ -40,12 +42,17 @@ const ExperimentOverview = { this.scenarioMode = false; // Fetch plan (overview) and strategy (rules) in parallel so tab-switching // between the two views doesn't require a second round-trip. - const [plan, strategy] = await Promise.all([ + // D2: also fetch roster + roles for the roster lens. + const [plan, strategy, rosterEmbyros, rolesMap] = await Promise.all([ this.loadPlan(), - this.loadStrategy() + this.loadStrategy(), + this._loadRoster(), + this._loadRolesMap(), ]); this.activePlan = plan; this.activeStrategy = strategy; + this._rosterEmbyros = rosterEmbyros; + this._rolesMap = rolesMap; this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; @@ -116,12 +123,17 @@ const ExperimentOverview = { // Debounced plan refetch — coalesces rapid tactic-event bursts into a single // fetch+render. 500 ms window matches experiment-strip.js convention. + // D2: also re-fetches the embryo roster so the lens stays current. _debouncedRefresh() { if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); this._planRefreshTimer = setTimeout(async () => { this._planRefreshTimer = null; - const plan = await this.loadPlan(); + const [plan, rosterEmbyros] = await Promise.all([ + this.loadPlan(), + this._loadRoster(), + ]); this.activePlan = plan; + this._rosterEmbyros = rosterEmbyros; this.isLive = plan !== null; this.render(this.activeStrategy); }, 500); @@ -338,8 +350,24 @@ const ExperimentOverview = { // Index of the first queued (planned) tactic — gets the "next" badge. const firstPlannedIdx = tactics.findIndex(t => t.state === 'planned'); + // Roster lens: visible when embryos + role metadata are both available. + // Gracefully absent if either fetch failed or returned nothing (backward compat + // with plans that pre-date D2 — spine renders exactly as before). + const rosterEmbyros = this._rosterEmbyros || []; + const rolesMap = this._rolesMap; + const rosterCtx = (rosterEmbyros.length > 0 && rolesMap && rolesMap.size > 0) + ? { embryos: rosterEmbyros, rolesMap } + : null; + const rosterHtml = rosterCtx + ? ` + ${this._renderRosterLens(rosterEmbyros, rolesMap, plan, ESC)}` + : ''; + const spineLabel = rosterCtx + ? '' + : ''; + const spineNodes = tactics - .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC)) + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx)) .join(''); root.innerHTML = ` @@ -352,12 +380,15 @@ const ExperimentOverview = { in use queued
    + ${rosterHtml} + ${spineLabel}
    ${spineNodes}
    `; }, // Render a single tactic node. - _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { + // rosterCtx = { embryos, rolesMap } | null — when present, adds role-scope badge (D2). + _renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx = null) { const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued', paused: 'paused' }; const seq = String(t.seq || idx + 1).padStart(2, '0'); const stateLabel = STATE_LABEL[t.state] || t.state; @@ -372,11 +403,17 @@ const ExperimentOverview = { const summary = live.summary || ''; const desc = live.desc || ''; - // Header row: name · target · summary + // Scope badge — D2: shows role → embryo-ids when roster context is available. + const scopeBadge = rosterCtx + ? this._renderOpsScopeBadge(t.scope, rosterCtx.embryos, rosterCtx.rolesMap, ESC) + : ''; + + // Header row: name · target · scope badge · summary let inner = `
    ${ESC(t.name)} ${target ? `${ESC(target)}` : ''} + ${scopeBadge} ${summary ? `${ESC(summary)}` : ''}
    ${desc ? `
    ${ESC(desc)}
    ` : ''}`; @@ -524,6 +561,210 @@ const ExperimentOverview = { return ''; }, + // ================================================================= + // D2 — Roster Lens: embryo population by role class + role + // ================================================================= + + // Fetch the current embryo roster from /api/embryos/positions. + // Returns [] on failure or when no embryos have positions yet. + async _loadRoster() { + try { + const resp = await fetch('/api/embryos/positions', { cache: 'no-store' }); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data.embryos) ? data.embryos : []; + } catch (e) { + console.warn('[ExperimentOverview] roster fetch error:', e); + return []; + } + }, + + // Fetch the roles registry from /api/roles. + // Returns a Map(name → {ui_color, ui_icon, role_class, default_cadence_seconds}). + // Returns empty Map on failure. + async _loadRolesMap() { + try { + const resp = await fetch('/api/roles', { cache: 'no-store' }); + if (!resp.ok) return new Map(); + const data = await resp.json(); + const map = new Map(); + if (Array.isArray(data.roles)) { + for (const r of data.roles) { + map.set(r.name, r); + } + } + return map; + } catch (e) { + console.warn('[ExperimentOverview] roles fetch error:', e); + return new Map(); + } + }, + + // Convert a hex color (#rrggbb) to rgba(r,g,b,alpha) for inline styles. + _hexToRgba(hex, alpha) { + const h = (hex || '#888888').replace('#', ''); + const r = parseInt(h.slice(0, 2), 16) || 0; + const g = parseInt(h.slice(2, 4), 16) || 0; + const b = parseInt(h.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${alpha})`; + }, + + // Cross-reference: find the name of the active (or most recently done) tactic + // that covers a given embryo (by embryo_id + role). + // Scope resolution: global → covers all; role → covers matching role; + // embryos → covers listed ids. Returns '' when no tactic found. + _resolveCurrentTactic(embryoId, role, plan) { + if (!plan || !Array.isArray(plan.tactics)) return ''; + const covers = (t) => { + const scope = t.scope || { mode: 'global' }; + if (scope.mode === 'global') return true; + if (scope.mode === 'role') return scope.role === role; + if (scope.mode === 'embryos') { + return Array.isArray(scope.embryo_ids) && scope.embryo_ids.includes(embryoId); + } + return false; + }; + // Prefer the active tactic covering this embryo. + const active = plan.tactics.find(t => t.state === 'active' && covers(t)); + if (active) return active.name; + // Fall back to the most recently done tactic (last in array order). + for (let i = plan.tactics.length - 1; i >= 0; i--) { + if (plan.tactics[i].state === 'done' && covers(plan.tactics[i])) { + return plan.tactics[i].name; + } + } + return ''; + }, + + // Render a scope badge for a tactic node in the spine. + // Colors for role-scoped badges come from the roles registry (API), not CSS. + // Global and explicit-embryos scopes use the static `.ops-scope-global` class. + _renderOpsScopeBadge(scope, embryos, rolesMap, ESC) { + if (!scope || scope.mode === 'global') { + return '→ all embryos'; + } + if (scope.mode === 'role') { + const role = scope.role || ''; + const roleInfo = rolesMap ? rolesMap.get(role) : null; + const color = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const matchIds = embryos + .filter(e => e.role === role) + .map(e => e.embryo_id) + .join(', '); + const label = matchIds + ? `→ ${ESC(role)} · ${ESC(matchIds)}` + : `→ ${ESC(role)}`; + const bg = this._hexToRgba(color, 0.12); + const border = this._hexToRgba(color, 0.35); + return `${label}`; + } + if (scope.mode === 'embryos') { + const ids = ESC((scope.embryo_ids || []).join(', ')); + return `→ ${ids}`; + } + return ''; + }, + + // Render the D2 roster lens: embryos grouped by role class then by role. + // SUBJECTS section is foregrounded; REFERENCES section is compact/muted. + // Role colors/icons come from the rolesMap (API data), not from CSS constants. + // Returns empty string when embryos array is empty (backward compat). + _renderRosterLens(embryos, rolesMap, plan, ESC) { + if (!embryos || embryos.length === 0) return ''; + + // Group embryos by role_class then by role, preserving first-seen order. + const CLASS_ORDER_PREF = ['subject', 'reference']; + const classOrder = []; + const byClass = {}; + + for (const emb of embryos) { + const roleInfo = rolesMap.get(emb.role); + const cls = (roleInfo && roleInfo.role_class) || 'subject'; + if (!byClass[cls]) { + byClass[cls] = { roleOrder: [], byRole: {} }; + classOrder.push(cls); + } + const section = byClass[cls]; + if (!section.byRole[emb.role]) { + section.byRole[emb.role] = []; + section.roleOrder.push(emb.role); + } + section.byRole[emb.role].push(emb); + } + + // Canonical class order: subjects first. + classOrder.sort((a, b) => { + const ai = CLASS_ORDER_PREF.indexOf(a); + const bi = CLASS_ORDER_PREF.indexOf(b); + return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); + }); + + const totalCount = embryos.length; + const totalRoles = classOrder.reduce((n, cls) => n + byClass[cls].roleOrder.length, 0); + + const classSections = classOrder.map(cls => { + const { roleOrder, byRole } = byClass[cls]; + const isSubject = cls === 'subject'; + + const classHeaderInner = isSubject + ? `Subjects— adaptive tactics / scenarios` + : `References— steady acquisition`; + + const roleGroups = roleOrder.map(role => { + const roleEmbyros = byRole[role]; + const roleInfo = rolesMap.get(role); + const uiColor = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const uiIcon = (roleInfo && roleInfo.ui_icon) || ''; + const bgRgba = this._hexToRgba(uiColor, 0.08); + const ids = roleEmbyros.map(e => e.embryo_id).join(', '); + + const embryoRows = roleEmbyros.map(emb => { + const cadencePhase = emb.cadence_phase || 'normal'; + const strain = emb.strain || '—'; + const label = emb.user_label || emb.embryo_id; + const tacticName = this._resolveCurrentTactic(emb.embryo_id, emb.role, plan); + const stateStr = emb.is_complete ? 'done' + : cadencePhase === 'paused' ? 'paused' + : 'active'; + const compact = isSubject ? '' : ' compact'; + const chipBg = this._hexToRgba(uiColor, 0.15); + const chipBorder = this._hexToRgba(uiColor, 0.4); + return `
    + ${ESC(label)} + ${uiIcon ? ESC(uiIcon) + ' ' : ''}${ESC(role)} + ${ESC(strain)} + ${ESC(cadencePhase)} + ${ESC(tacticName || '—')} + ${ESC(stateStr)} +
    `; + }).join(''); + + return `
    +
    + ${ESC(role.toUpperCase())} + · + ${roleEmbyros.length} embryo${roleEmbyros.length !== 1 ? 's' : ''} + ${ESC(ids)} +
    + ${embryoRows} +
    `; + }).join(''); + + return `
    +
    ${classHeaderInner}
    + ${roleGroups} +
    `; + }).join(''); + + return `
    +
    + Population roster + ${totalCount} embryo${totalCount !== 1 ? 's' : ''} · ${totalRoles} role${totalRoles !== 1 ? 's' : ''} +
    + ${classSections} +
    `; + }, + // ----------------------------------------------------------------- // Rules table — full subtab view, grouped by rule kind // ----------------------------------------------------------------- From 866d4e08fc96d7abd7f9ea0bf41c2ba71ccc04d2 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:47:06 +0530 Subject: [PATCH 140/200] fix(d2): surface strain through timelapse_tracker + rename _rosterEmbryos + resolver-coupling note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timelapse_tracker.py: EMBRYO_DETECTED handler now sets emb["strain"] when "strain" key present in data (mirrors role handling); seed_from_experiment now passes strain=getattr(emb, "strain", None) in the EMBRYO_DETECTED payload — fixes strain always showing "—" in live roster. - experiment-overview.js: rename _rosterEmbyros → _rosterEmbryos (9 sites, property + local variable); add coupling note on _resolveCurrentTactic pointing to role_scope.py resolve_scope_embryos. - tests/test_embryo_strain.py: 4 new tracker tests (EMBRYO_DETECTED handler + seed_from_experiment with/without strain); all 10 pass. --- .../ui/web/static/js/experiment-overview.js | 19 ++-- gently/ui/web/timelapse_tracker.py | 3 + tests/test_embryo_strain.py | 89 +++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index f480ea1a..fac90551 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -19,7 +19,7 @@ const ExperimentOverview = { _subscribed: false, // guard: prevents double-registration across tab re-clicks _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed - _rosterEmbyros: [], // embryos from /api/embryos/positions (D2 roster lens) + _rosterEmbryos: [], // embryos from /api/embryos/positions (D2 roster lens) _rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens) async init() { @@ -43,7 +43,7 @@ const ExperimentOverview = { // Fetch plan (overview) and strategy (rules) in parallel so tab-switching // between the two views doesn't require a second round-trip. // D2: also fetch roster + roles for the roster lens. - const [plan, strategy, rosterEmbyros, rolesMap] = await Promise.all([ + const [plan, strategy, rosterEmbryos, rolesMap] = await Promise.all([ this.loadPlan(), this.loadStrategy(), this._loadRoster(), @@ -51,7 +51,7 @@ const ExperimentOverview = { ]); this.activePlan = plan; this.activeStrategy = strategy; - this._rosterEmbyros = rosterEmbyros; + this._rosterEmbryos = rosterEmbryos; this._rolesMap = rolesMap; this.isLive = plan !== null || strategy !== null; this.render(strategy); @@ -128,12 +128,12 @@ const ExperimentOverview = { if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); this._planRefreshTimer = setTimeout(async () => { this._planRefreshTimer = null; - const [plan, rosterEmbyros] = await Promise.all([ + const [plan, rosterEmbryos] = await Promise.all([ this.loadPlan(), this._loadRoster(), ]); this.activePlan = plan; - this._rosterEmbyros = rosterEmbyros; + this._rosterEmbryos = rosterEmbryos; this.isLive = plan !== null; this.render(this.activeStrategy); }, 500); @@ -353,14 +353,14 @@ const ExperimentOverview = { // Roster lens: visible when embryos + role metadata are both available. // Gracefully absent if either fetch failed or returned nothing (backward compat // with plans that pre-date D2 — spine renders exactly as before). - const rosterEmbyros = this._rosterEmbyros || []; + const rosterEmbryos = this._rosterEmbryos || []; const rolesMap = this._rolesMap; - const rosterCtx = (rosterEmbyros.length > 0 && rolesMap && rolesMap.size > 0) - ? { embryos: rosterEmbyros, rolesMap } + const rosterCtx = (rosterEmbryos.length > 0 && rolesMap && rolesMap.size > 0) + ? { embryos: rosterEmbryos, rolesMap } : null; const rosterHtml = rosterCtx ? ` - ${this._renderRosterLens(rosterEmbyros, rolesMap, plan, ESC)}` + ${this._renderRosterLens(rosterEmbryos, rolesMap, plan, ESC)}` : ''; const spineLabel = rosterCtx ? '' @@ -613,6 +613,7 @@ const ExperimentOverview = { // that covers a given embryo (by embryo_id + role). // Scope resolution: global → covers all; role → covers matching role; // embryos → covers listed ids. Returns '' when no tactic found. + // NOTE: mirrors resolve_scope_embryos() in gently/app/orchestration/role_scope.py — keep in sync if a new scope mode is added. _resolveCurrentTactic(embryoId, role, plan) { if (!plan || !Array.isArray(plan.tactics)) return ''; const covers = (t) => { diff --git a/gently/ui/web/timelapse_tracker.py b/gently/ui/web/timelapse_tracker.py index f980001d..965e4019 100644 --- a/gently/ui/web/timelapse_tracker.py +++ b/gently/ui/web/timelapse_tracker.py @@ -216,6 +216,8 @@ def handle_event(self, event_type: str, data: dict): emb["stage_y_um"] = data["y"] if data.get("role"): emb["role"] = data["role"] + if "strain" in data: + emb["strain"] = data.get("strain") if data.get("uid"): emb["uid"] = data["uid"] if data.get("user_label"): @@ -486,6 +488,7 @@ def seed_from_experiment(self, experiment) -> int: "x": x, "y": y, "role": getattr(emb, "role", "test"), + "strain": getattr(emb, "strain", None), "user_label": getattr(emb, "user_label", None), "confidence": getattr(emb, "detection_confidence", None), }, diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py index 474a77ef..7f2836eb 100644 --- a/tests/test_embryo_strain.py +++ b/tests/test_embryo_strain.py @@ -143,3 +143,92 @@ def test_positions_endpoint_strain_none_when_absent(): point = resp.json()["embryos"][0] assert "strain" in point assert point["strain"] is None + + +# =========================================================================== +# TimelapseStateTracker – Fix 1 (strain surfaces through tracker) +# =========================================================================== + + +def test_tracker_embryo_detected_sets_strain(): + """EMBRYO_DETECTED with strain populates the tracker embryo dict.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e1", + "x": 100.0, + "y": 200.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + }, + ) + assert tracker.embryos["e1"]["strain"] == "pan-nuclear GFP" + + +def test_tracker_embryo_detected_strain_none_propagates(): + """EMBRYO_DETECTED with explicit strain=None still writes the key.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e2", + "x": 0.0, + "y": 0.0, + "role": "test", + "strain": None, + }, + ) + assert "strain" in tracker.embryos["e2"] + assert tracker.embryos["e2"]["strain"] is None + + +def test_tracker_seed_from_experiment_includes_strain(): + """seed_from_experiment copies strain from EmbryoState into tracker.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 50.0, "y": 60.0}, + uid="u1", + role="test", + strain="H2B-mCherry", + user_label=None, + detection_confidence=0.95, + ) + experiment = SimpleNamespace(embryos={"e3": emb}) + + tracker = TimelapseStateTracker() + seeded = tracker.seed_from_experiment(experiment) + + assert seeded == 1 + assert tracker.embryos["e3"]["strain"] == "H2B-mCherry" + + +def test_tracker_seed_from_experiment_strain_absent_is_none(): + """seed_from_experiment with no strain attribute → tracker strain is None.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 10.0, "y": 20.0}, + uid="u2", + role="control", + # no .strain attribute + user_label=None, + detection_confidence=None, + ) + experiment = SimpleNamespace(embryos={"e4": emb}) + + tracker = TimelapseStateTracker() + tracker.seed_from_experiment(experiment) + + assert "strain" in tracker.embryos["e4"] + assert tracker.embryos["e4"]["strain"] is None From 4353c10d71f1353eef0e60215f626ba13fb1ceb9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:47:31 +0530 Subject: [PATCH 141/200] chore(d2): untrack .superpowers SDD scratch (committed by a subagent) --- .superpowers/sdd/d2-task-2-report.md | 106 --------------------------- 1 file changed, 106 deletions(-) delete mode 100644 .superpowers/sdd/d2-task-2-report.md diff --git a/.superpowers/sdd/d2-task-2-report.md b/.superpowers/sdd/d2-task-2-report.md deleted file mode 100644 index 5a9a33c4..00000000 --- a/.superpowers/sdd/d2-task-2-report.md +++ /dev/null @@ -1,106 +0,0 @@ -# D2 Task 2 Report — roles-as-use: lineaging + subject/reference class - -## What was done - -### 1. `role_class` field added to `EmbryoRole` - -Added `role_class: str = "subject"` as the last field in the frozen dataclass -(`gently/harness/roles.py`, after `no_object_consecutive_terminal`). Default is -`"subject"` so any existing keyword-constructed `EmbryoRole` is safe without -change. Docstring in the module header updated to document the two values -(`"subject"` | `"reference"`) and their intent. - -### 2. `role_class` set on all three existing REGISTRY entries - -| Role | role_class | Rationale | -|-------------|-------------|------------------------------------------------| -| `test` | `"subject"` | The biological subject — the precious sample. | -| `unassigned`| `"subject"` | Safe default: protect like a subject until resolved. | -| `calibration`| `"reference"` | Reference embryo for staging/calibration. | - -### 3. `lineaging` entry added to REGISTRY - -```python -"lineaging": EmbryoRole( - name="lineaging", - description=( - "Lineage-tracing reference — tracks nuclei/divisions; often a " - "pan-nuclear strain but the strain is separate from this use." - ), - default_cadence_seconds=300.0, - detector_name="perception", # nuclear pipeline, same as calibration - photodose_budget_multiplier=5.0, - ui_color="#33cc88", # teal-green — distinct from cyan/magenta - ui_icon="triangle", - no_object_consecutive_terminal=2, # reference: gone == gone - role_class="reference", -) -``` - -Color distinctness verified: `#33cc88` (teal-green) ≠ `#00cccc` (calibration -cyan) ≠ `#ff66cc` (test magenta). - -### 4. EmbryoRole construction-site check - -``` -grep -rn "EmbryoRole(" gently/ -``` - -Result: only `gently/harness/roles.py` — the three (now four) REGISTRY entries, -all using keyword arguments. No positional construction anywhere. The new field's -default makes it safe for any future keyword construction too. - -### 5. What was NOT touched (detector — staged) - -- No changes to detector/perception wiring, session logic, cadence policy, - or any consumer of `EmbryoRole` fields. -- `detector_name="perception"` on `lineaging` mirrors `calibration` — the - staged approach defers any strain→detector decoupling to spec §4 future work. -- `DEFAULT_ROLE`, `get_role`, `is_valid_role`, `list_roles` — all unchanged - (they work generically over REGISTRY). - -## TDD evidence - -File: `tests/test_roles_registry.py` (created, 22 tests) - -``` -tests/test_roles_registry.py::test_test_role_class_is_subject PASSED -tests/test_roles_registry.py::test_unassigned_role_class_is_subject PASSED -tests/test_roles_registry.py::test_calibration_role_class_is_reference PASSED -tests/test_roles_registry.py::test_lineaging_role_exists_in_registry PASSED -tests/test_roles_registry.py::test_lineaging_role_class_is_reference PASSED -tests/test_roles_registry.py::test_lineaging_ui_color PASSED -tests/test_roles_registry.py::test_lineaging_ui_icon PASSED -tests/test_roles_registry.py::test_lineaging_description_contains_lineage PASSED -tests/test_roles_registry.py::test_lineaging_has_cadence PASSED -tests/test_roles_registry.py::test_get_role_lineaging_returns_correct_entry PASSED -tests/test_roles_registry.py::test_is_valid_role_lineaging_true PASSED -tests/test_roles_registry.py::test_is_valid_role_unknown_false PASSED -tests/test_roles_registry.py::test_list_roles_includes_lineaging PASSED -tests/test_roles_registry.py::test_list_roles_sorted PASSED -tests/test_roles_registry.py::test_get_role_unknown_raises_key_error PASSED -tests/test_roles_registry.py::test_test_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_calibration_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_unassigned_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_default_role_is_in_registry PASSED -tests/test_roles_registry.py::test_default_role_is_test PASSED -tests/test_roles_registry.py::test_embryo_role_is_frozen PASSED -tests/test_roles_registry.py::test_embryo_role_default_role_class PASSED -22 passed in 0.12s -``` - -Full suite (excluding 3 pre-existing collection errors): -- Before task-2 changes: 41 pre-existing failures + 4 from this test file against old code = 45 total -- After task-2 changes: 32 failures, 765 passed — all 32 failures pre-existing (test_eval, test_mesh_service, test_prompt_manager and others), zero new failures introduced. - -## Files changed - -- `gently/harness/roles.py` — `role_class` field on dataclass + all 4 registry entries -- `tests/test_roles_registry.py` — new, 22 tests - -## Concerns - -None. The change is additive and isolated to `roles.py`. The frozen dataclass -with a defaulted field is backward-compatible. The `lineaging` detector choice -(`"perception"`) is intentionally the same as calibration — decoupling -detector-from-use is explicitly deferred to spec §4. From a7e41e5b344113b80c48437749ab1069b4406c15 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:15:16 +0530 Subject: [PATCH 142/200] =?UTF-8?q?docs(f):=20session=E2=86=94plans=20link?= =?UTF-8?q?/delink=20spec=20+=20plan=20(4=20tasks);=20base=20plans=20defer?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-29-session-plan-linking.md | 39 +++++++++++ .../2026-06-29-session-plan-linking-design.md | 64 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-session-plan-linking.md create mode 100644 docs/superpowers/specs/2026-06-29-session-plan-linking-design.md diff --git a/docs/superpowers/plans/2026-06-29-session-plan-linking.md b/docs/superpowers/plans/2026-06-29-session-plan-linking.md new file mode 100644 index 00000000..f58b8a41 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-session-plan-linking.md @@ -0,0 +1,39 @@ +# Session ↔ plans link/delink (F) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Let a session link to multiple plan items, with link/delink from both the Plans tab and the session view. Source of truth = `PlanItem.session_ids` (reverse-query for a session's plans). Base plans deferred. + +**Architecture:** Data-layer `unlink_plan_item_session` + `get_plan_items_for_session` (reverse query); link/delink HTTP endpoints; link/delink UI on the Plans-tab item detail + a "Linked plans" panel on the session/Operations view. + +## Global Constraints +- Source of truth for session↔plan-item linkage = `PlanItem.session_ids` (a list; `link_plan_item_session` appends — `file_store.py:1415`). A session's plans = reverse query. Do NOT add a plan_item list to SessionIntent. +- Campaign edge stays `SessionIntent.campaign_ids` (`link/unlink_session_campaign`). Linking a session to a plan item also `link_session_campaign(item.campaign_id)`. Delink (v1) only removes the plan-item edge. +- Endpoints mirror `gently/ui/web/routes/campaigns.py` (the item-detail route at :212, PATCH at :324). UI mirrors `campaigns.js` item-detail rendering (~:789-802 Sessions section). +- Repo/base plans DEFERRED (spec §4) — do not build. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Data layer — unlink + reverse-query +**Files:** Modify `gently/harness/memory/file_store.py` — add `unlink_plan_item_session(item_id, session_id)` (remove session from the item's `session_ids`, clear back-compat `session_id` if equal, persist to the campaign plan, fire `_notify_plan_change`; idempotent if absent) and `get_plan_items_for_session(session_id) -> list[PlanItem]` (iterate `get_active_campaigns()` → `get_plan_items(campaign_id)` → filter `session_id in (item.session_ids or [])`). Test: `tests/test_session_plan_linking_store.py`. +- [ ] Confirm `link_plan_item_session` (:1415) — how it appends to session_ids + persists the plan item to `plan/current.yaml` — and mirror the persistence for unlink. Confirm `get_active_campaigns`/`get_plan_items`. +- [ ] TDD: link a session to 2 items (different campaigns) → `get_plan_items_for_session` returns both; `unlink_plan_item_session` removes it from one (other remains); unlink absent → no-op; back-compat `session_id` cleared when it matched. `pytest tests/test_session_plan_linking_store.py -v`; `pytest -q` clean. Commit `feat(f): unlink_plan_item_session + get_plan_items_for_session`. + +### Task 2: Link/delink endpoints +**Files:** Modify `gently/ui/web/routes/campaigns.py` — `POST /api/campaigns/{cid}/items/{item_id}/sessions` (body `{session_id}` → `link_plan_item_session` + `link_session_campaign`; return updated sessions) and `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` (→ `unlink_plan_item_session`); add `GET /api/sessions/{id}/plans` in `gently/ui/web/routes/sessions.py` (→ `get_plan_items_for_session`, return `[{id,title,campaign_id,status}]`). Test: `tests/test_session_plan_linking_routes.py`. +- [ ] Mirror the campaigns route store resolution (`server.context_store`/`gently_store`) + the item-detail route (:212). The session-plans route mirrors `sessions.py` resolution. Graceful (404 item/session; empty list). +- [ ] TDD (TestClient + mock store): POST links (store.link_plan_item_session + link_session_campaign called); DELETE delinks (unlink called); GET returns the session's plans; bad ids handled. `pytest tests/test_session_plan_linking_routes.py -v`; `pytest -q` clean. Commit `feat(f): session↔plan link/delink endpoints`. + +### Task 3: Plans-tab link/delink UI +**Files:** Modify `gently/ui/web/static/js/campaigns.js` (the item-detail Sessions section ~:789-802 — add a `[+ link session]` picker (sessions from `/api/sessions`) + a `[delink]` button per session, calling the POST/DELETE endpoints then re-rendering the item) + `gently/ui/web/static/css/campaigns.css` (the control styles). +- [ ] Render the link control + per-session delink in the item-detail Sessions list; wire to the endpoints; optimistic re-render / refetch the item on success; keep the "No linked sessions" empty state with the link control. `node --check`; build a Chrome-MCP harness (real campaigns.js + a stubbed item-detail + /api/sessions) for the controller to audit. Commit `feat(f): Plans-tab session link/delink controls`. + +### Task 4: Session "Linked plans" panel +**Files:** Modify the session/Operations view JS (`gently/ui/web/static/js/experiment-overview.js` or the session view `review.js` — whichever renders the active session header; CONFIRM which surface) — add a "Linked plans" panel listing `/api/sessions/{id}/plans` rows (title · campaign · status · `[delink]`) + a `[+ link to a plan]` picker (plan items from active campaigns via `/api/campaigns` tree); wire to the same endpoints; CSS as needed. +- [ ] Confirm the session-scoped render surface + how it knows the current session_id. Render the panel reading `/api/sessions/{id}/plans`; link/delink hit the same endpoints + refresh; symmetric with Task 3. Backward compat: no linked plans → a tidy empty state, no crash. `node --check`; Chrome-MCP harness audit. Commit `feat(f): session Linked-plans panel (link/delink)`. + +## Self-Review +- Data→T1; endpoints→T2; Plans-tab UI→T3; session panel→T4. ✓ +- Open confirmations: link_plan_item_session persistence (T1), campaigns/sessions route patterns (T2), the campaigns.js item-detail render seam (T3), the session-scoped render surface + current session_id (T4). +- Type consistency: plan-item linkage via `PlanItem.session_ids`; the endpoints + both UIs hit the same POST/DELETE/GET; a link from one surface appears on the other after refresh. diff --git a/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md new file mode 100644 index 00000000..4b3be8f6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md @@ -0,0 +1,64 @@ +# Design: Session ↔ plans link/delink (sub-project F) + +Status: design 2026-06-29 (after recon + user steering). Branch `feature/session-plan-linking` (off D2). +Lets a session link to MULTIPLE plan items, with link/delink from BOTH the Plans tab and the session +view. "Repo/base plans" is DEFERRED (user has ideas — separate follow-on). + +## 0. What exists (recon) +- Session↔**campaign**: many-to-many (`SessionIntent.campaign_ids` list; `link/unlink_session_campaign`). +- Session↔**plan-item**: `PlanItem.session_ids` is a LIST (a session can already appear under multiple + items in storage) — but a session's *own* notion of "its plans" is a single `active_plan_item_id` + pointer, and `SessionIntent` stores no plan-item ids. +- `attach_session_to_plan` appends to `item.session_ids` (via `link_plan_item_session`) but overwrites + the single active pointer; `detach_session_from_plan` only clears the pointer (NOT a data delink). +- Plans tab (`campaigns.js:789-802`) shows a per-item read-only Sessions list ("No linked sessions"). +- NO link/delink endpoint or UI anywhere; NO `unlink_plan_item_session`; session endpoint returns no linkage. + +## 1. The model — source of truth = `PlanItem.session_ids` +A session's linked plan items = the reverse query over plan items whose `session_ids` includes the +session. No new field on SessionIntent (avoids dual source of truth). Multi-plan falls out naturally +(a session can be in many items' `session_ids`). The campaign edge stays on `SessionIntent.campaign_ids`. + +- **Link** session↔plan-item: `link_plan_item_session(item_id, session_id)` (exists, appends) + + `link_session_campaign(session_id, item.campaign_id)` (exists). +- **Delink**: NEW `unlink_plan_item_session(item_id, session_id)` — remove the session from + `item.session_ids` (+ clear the back-compat `session_id` if it pointed there); fire `_notify_plan_change`. + Campaign edge: leave it unless no other item of that campaign links the session (refinement — for v1, + delink only touches the plan-item edge; campaign delink stays the existing separate control). +- **Session's plans**: NEW `get_plan_items_for_session(session_id) -> list[PlanItem]` (reverse query + across `get_active_campaigns` → `get_plan_items` → filter `session_id in item.session_ids`). + +## 2. Endpoints (mirror `routes/campaigns.py`) +- `POST /api/campaigns/{cid}/items/{item_id}/sessions` body `{session_id}` → link (link_plan_item_session + + link_session_campaign). Returns the updated item sessions. +- `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` → delink (unlink_plan_item_session). +- `GET /api/sessions/{id}/plans` → the session's linked plan items (id, title, campaign_id, status) via + `get_plan_items_for_session`. (A new sub-route; leaves the existing session payload untouched.) + +## 3. UI — both surfaces +### 3.1 Plans tab item-detail (`campaigns.js` ~:789-802) +The existing per-item Sessions list gains: a **[+ link session]** control (a picker of recent sessions +from `/api/sessions`) and a **[delink]** button per listed session. Calls the POST/DELETE endpoints, +re-renders the item detail. Empty state keeps "No linked sessions" + the link control. + +### 3.2 Session / Operations view — "Linked plans" panel +A panel (in the Operations/experiment view header or a session detail strip) listing the session's +linked plan items (from `/api/sessions/{id}/plans`): each row `plan item title · campaign · status · +[delink]`, plus **[+ link to a plan]** (a picker of plan items from the active campaigns). Symmetric +with 3.1 — link/delink from either side; both hit the same endpoints + refresh. + +## 4. Out of scope (deferred) +- **Repo/base plans** — the user has ideas; a separate follow-on (a repo plans library / seed). Noted, + not built here. +- Campaign-edge auto-cleanup on plan delink (v1 leaves the campaign link; refine later). +- Reworking `attach_session_to_plan`/`detach` agent tools beyond what's needed — the data-layer + delink (`unlink_plan_item_session`) is added; wiring a `detach` that calls it is a small optional add. + +## 5. Testing +- Data layer: `unlink_plan_item_session` removes the session (+ back-compat session_id); idempotent on + absent; `get_plan_items_for_session` reverse-query returns the right items across campaigns; multi-plan + (a session under 2 items) round-trips. +- Endpoints: POST links (item.session_ids gains it + campaign linked); DELETE delinks; GET returns the + session's plans; mirror `tests/test_*route*` with a mock store. +- UI: link/delink controls on both surfaces (node --check + Chrome audit of the Plans-tab item detail + + the session "Linked plans" panel); link from one side shows on the other after refresh. From 775392b418750186b5ec9f4d6c86512c881e6c62 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:19:33 +0530 Subject: [PATCH 143/200] feat(f): unlink_plan_item_session + get_plan_items_for_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two missing data-layer pieces for session↔plan linking (Task 1): - unlink_plan_item_session: mirrors link's load/persist/notify; idempotent; clears back-compat session_id scalar when the last session is removed. - get_plan_items_for_session: reverse query across active campaigns; deduped by item id; back-compat via _dict_to_plan_item's existing normalisation. 10 new tests, 0 regressions. --- gently/harness/memory/file_store.py | 45 ++++++ tests/test_session_plan_linking_store.py | 167 +++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tests/test_session_plan_linking_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 6eaab365..2e4fb23c 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1437,6 +1437,51 @@ def link_plan_item_session( self._notify_plan_change(campaign_id) return True + def unlink_plan_item_session(self, item_id: str, session_id: str) -> bool: + """Remove a session from a plan item's session_ids list. + + Mirrors the load/persist/notify pattern of link_plan_item_session. + Clears the back-compat scalar session_id when it matched the removed + session (sets it to the most-recent remaining session_id, or None). + Idempotent: returns False without writing if the session isn't linked. + Returns True on successful removal. + """ + loc = self._find_plan_item_location(item_id) + if not loc: + return False + campaign_id, items, idx = loc + item = items[idx] + sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else []) + if session_id not in sids: + return False + sids = [s for s in sids if s != session_id] + item["session_ids"] = sids + item["session_id"] = sids[-1] if sids else None # back-compat: most recent remaining + item["updated_at"] = self._now() + self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) + return True + + def get_plan_items_for_session(self, session_id: str) -> list["PlanItem"]: + """Return all plan items linked to a session. + + Iterates active campaigns only (mirrors the normal read path). + Back-compat: matches items whose scalar session_id equals the query even + when session_ids is empty (old items written before the list field existed). + Deduplicates by item id. + """ + seen: set[str] = set() + result: list[PlanItem] = [] + for campaign in self.get_active_campaigns(): + for item in self.get_plan_items(campaign.id): + if item.id in seen: + continue + # session_ids is already populated from back-compat in _dict_to_plan_item + if session_id in (item.session_ids or []) or item.session_id == session_id: + seen.add(item.id) + result.append(item) + return result + def skip_plan_item(self, item_id: str, reason: str | None = None): self.update_plan_item( item_id, diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py new file mode 100644 index 00000000..5cd72466 --- /dev/null +++ b/tests/test_session_plan_linking_store.py @@ -0,0 +1,167 @@ +""" +FileContextStore: session ↔ plan-item link/delink (Task 1 — data layer). + +Tests: +- link a session to items in 2 different campaigns +- get_plan_items_for_session returns both items +- unlink_plan_item_session removes one (the other remains) +- get_plan_items_for_session returns only the remaining item after unlink +- unlinking a session that isn't linked → False, no side-effects +- back-compat scalar session_id is cleared when the unlinked session matched it +""" + +import pytest + +from gently.harness.memory.model import PlanItemStatus, PlanItemType + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item(store, campaign_id: str, title: str) -> str: + """Create a bench plan item and return its id.""" + return store.create_plan_item( + campaign_id=campaign_id, + type=PlanItemType.BENCH.value, + title=title, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def two_campaigns(file_context_store): + """Two active campaigns, each with one plan item. Returns (store, cid1, cid2, item1, item2).""" + store = file_context_store + cid1 = store.create_campaign(description="Campaign Alpha") + cid2 = store.create_campaign(description="Campaign Beta") + item1 = _make_item(store, cid1, "Step A — bench prep") + item2 = _make_item(store, cid2, "Step B — gel run") + return store, cid1, cid2, item1, item2 + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestLinkAndReverseQuery: + def test_link_and_get_plan_items_for_session(self, two_campaigns): + """Linking a session to 2 items across 2 campaigns; reverse query returns both.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_abc" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + items = store.get_plan_items_for_session(session_id) + ids = {it.id for it in items} + assert item1 in ids + assert item2 in ids + assert len(ids) == 2 + + def test_session_not_in_unrelated_item(self, two_campaigns): + """Items not linked to the session don't appear in the reverse query.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_xyz" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + items = store.get_plan_items_for_session(session_id) + assert len(items) == 1 + assert items[0].id == item1 + + def test_unknown_session_returns_empty(self, two_campaigns): + """A session with no links → empty list, no error.""" + store, *_ = two_campaigns + result = store.get_plan_items_for_session("sess_ghost") + assert result == [] + + +class TestUnlink: + def test_unlink_removes_from_one_campaign(self, two_campaigns): + """Unlinking a session from one item leaves the other item still linked.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_def" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + remaining = store.get_plan_items_for_session(session_id) + ids = {it.id for it in remaining} + assert item1 not in ids + assert item2 in ids + + def test_unlink_idempotent_returns_false(self, two_campaigns): + """Unlinking a session that isn't linked returns False without crashing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + result = store.unlink_plan_item_session(item1, "sess_nothere") + assert result is False + + def test_unlink_unknown_item_returns_false(self, two_campaigns): + """Unlinking from an item that doesn't exist returns False.""" + store, *_ = two_campaigns + result = store.unlink_plan_item_session("item_ghost", "sess_x") + assert result is False + + def test_unlink_no_side_effect_when_not_linked(self, two_campaigns): + """After a no-op unlink, the item's session_ids are unchanged.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_linked" + store.link_plan_item_session(item1, session_id, set_in_progress=False) + + store.unlink_plan_item_session(item1, "sess_other") # different session — no-op + + item = store.get_plan_item(item1) + assert session_id in item.session_ids + + def test_full_unlink_clears_all_sessions(self, two_campaigns): + """After unlinking all sessions, get_plan_items_for_session returns nothing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_ghi" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.unlink_plan_item_session(item1, session_id) + + remaining = store.get_plan_items_for_session(session_id) + assert remaining == [] + + +class TestBackCompatSessionId: + def test_back_compat_cleared_on_unlink_when_matched(self, two_campaigns): + """Back-compat scalar session_id is set to None when the last session is unlinked.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_jkl" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + item_before = store.get_plan_item(item1) + assert item_before.session_id == session_id # back-compat set by link + + store.unlink_plan_item_session(item1, session_id) + + item_after = store.get_plan_item(item1) + assert item_after.session_id is None + assert item_after.session_ids == [] + + def test_back_compat_set_to_most_recent_remaining(self, two_campaigns): + """When one of two sessions is unlinked, back-compat session_id = remaining session.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + sid1 = "sess_first" + sid2 = "sess_second" + + store.link_plan_item_session(item1, sid1, set_in_progress=False) + store.link_plan_item_session(item1, sid2, set_in_progress=False) + + store.unlink_plan_item_session(item1, sid1) + + item = store.get_plan_item(item1) + assert sid2 in item.session_ids + assert item.session_id == sid2 # most recent remaining + assert sid1 not in item.session_ids From 26ea06a88f1d53a79179c3966078684259661a64 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:26:29 +0530 Subject: [PATCH 144/200] =?UTF-8?q?feat(f):=20session=E2=86=94plan=20link/?= =?UTF-8?q?delink=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/campaigns/{cid}/items/{item_id}/sessions — links a session to a plan item (link_plan_item_session + link_session_campaign) and returns the campaign's updated sessions. DELETE …/sessions/{sid} delinks via unlink_plan_item_session, returns {unlinked: bool}. GET /api/sessions/{session_id}/plans reads plan items for a session via get_plan_items_for_session, maps to [{id,title,campaign_id,status}]. 16/16 route tests pass; no new failures in suite (32 pre-existing). --- gently/ui/web/routes/campaigns.py | 32 +++ gently/ui/web/routes/sessions.py | 23 ++ tests/test_session_plan_linking_routes.py | 274 ++++++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 tests/test_session_plan_linking_routes.py diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index b0dbae11..857ed2db 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -254,6 +254,38 @@ async def get_item_detail(campaign_id: str, item_id: str): "sessions": [_serialize(s) for s in sessions], } + @router.post("/api/campaigns/{campaign_id}/items/{item_id}/sessions") + async def link_session_to_item(campaign_id: str, item_id: str, request: Request): + """Link a session to a plan item (appends) and record it against the campaign.""" + cs = _get_store() + campaign = _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + body = await request.json() + session_id = body.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="session_id required") + + cs.link_plan_item_session(item_id, session_id) + cs.link_session_campaign(session_id, campaign.id) + + sessions = cs.get_sessions_for_campaign(item.campaign_id) + return {"sessions": [_serialize(s) for s in sessions]} + + @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}") + async def unlink_session_from_item(campaign_id: str, item_id: str, session_id: str): + """Remove a session link from a plan item. Returns {unlinked: bool}.""" + cs = _get_store() + _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + unlinked = cs.unlink_plan_item_session(item_id, session_id) + return {"unlinked": unlinked} + @router.get("/api/campaigns/{campaign_id}/planned-sessions") async def get_planned_sessions(campaign_id: str): """Planned sessions linked to a campaign.""" diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index ccbc3a93..505e7f0d 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -204,6 +204,29 @@ async def resume_session(session_id: str): "rehydrated_projections": rehydrated, } + @router.get("/api/sessions/{session_id}/plans") + async def get_session_plans(session_id: str): + """Plan items linked to a session, via the context store.""" + cs = getattr(server, "context_store", None) + if cs is None: + return {"plans": []} + try: + items = cs.get_plan_items_for_session(session_id) + except Exception as e: + logger.warning("get_plan_items_for_session failed for %s: %s", session_id, e) + return {"plans": []} + return { + "plans": [ + { + "id": item.id, + "title": item.title, + "campaign_id": item.campaign_id, + "status": item.status.value, + } + for item in items + ] + } + @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): """Get session state for review, from the live FileStore. diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py new file mode 100644 index 00000000..b9542d4a --- /dev/null +++ b/tests/test_session_plan_linking_routes.py @@ -0,0 +1,274 @@ +"""Route tests: session ↔ plan link/delink HTTP endpoints (Task 2 of F). + +Endpoints under test: + POST /api/campaigns/{cid}/items/{item_id}/sessions → link session to item + DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{sid} → delink session from item + GET /api/sessions/{session_id}/plans → plan items for a session +""" + +from datetime import datetime +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.model import PlanItem, PlanItemStatus, PlanItemType, SessionIntent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item( + item_id: str, + campaign_id: str, + title: str, + status: PlanItemStatus = PlanItemStatus.PLANNED, +) -> PlanItem: + return PlanItem( + id=item_id, + campaign_id=campaign_id, + type=PlanItemType.BENCH, + title=title, + status=status, + ) + + +def _make_app(mock_cs): + """FastAPI test app with campaign + session routers wired to mock_cs.""" + from gently.ui.web.routes.campaigns import create_router as campaign_router + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _Server: + context_store = mock_cs + agent_bridge = None + gently_store = None + + server = _Server() + app.include_router(campaign_router(server)) + app.include_router(session_router(server)) + return app + + +def _mock_cs(item=None, sessions=None): + """Build a mock context store with sensible defaults.""" + cs = MagicMock() + cs.resolve_campaign.return_value = MagicMock(id="c1") + cs.get_plan_item.return_value = item + cs.get_sessions_for_campaign.return_value = sessions if sessions is not None else [] + cs.unlink_plan_item_session.return_value = True + cs.get_plan_items_for_session.return_value = [] + return cs + + +# --------------------------------------------------------------------------- +# POST /api/campaigns/{cid}/items/{item_id}/sessions +# --------------------------------------------------------------------------- + + +class TestPostLinkSession: + def test_calls_link_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + cs.link_plan_item_session.assert_called_once_with("i1", "s1") + + def test_calls_link_session_campaign_with_resolved_id(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.resolve_campaign.return_value = MagicMock(id="c1") + client = TestClient(_make_app(cs)) + + client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + cs.link_session_campaign.assert_called_once_with("s1", "c1") + + def test_returns_sessions_list(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item, sessions=[]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + body = r.json() + assert "sessions" in body + assert isinstance(body["sessions"], list) + + def test_returns_serialized_session_intents(self): + item = _make_item("i1", "c1", "Step A") + si = SessionIntent( + session_id="s1", + campaign_ids=["c1"], + created_at=datetime(2026, 6, 1), + ) + cs = _mock_cs(item=item, sessions=[si]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + sessions = r.json()["sessions"] + assert len(sessions) == 1 + assert sessions[0]["session_id"] == "s1" + assert sessions[0]["campaign_ids"] == ["c1"] + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/nope/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/nope/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_session_id_body_is_400(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={}) + + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id} +# --------------------------------------------------------------------------- + + +class TestDeleteUnlinkSession: + def test_calls_unlink_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + cs.unlink_plan_item_session.assert_called_once_with("i1", "s1") + + def test_returns_unlinked_true(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + assert r.json()["unlinked"] is True + + def test_returns_unlinked_false_when_not_linked(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = False + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s99") + + assert r.status_code == 200 + assert r.json()["unlinked"] is False + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/nope/sessions/s1") + + assert r.status_code == 404 + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/nope/items/i1/sessions/s1") + + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/sessions/{session_id}/plans +# --------------------------------------------------------------------------- + + +class TestGetSessionPlans: + def test_returns_mapped_plan_items(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [ + _make_item("i1", "c1", "Step A", PlanItemStatus.PLANNED), + _make_item("i2", "c2", "Step B", PlanItemStatus.IN_PROGRESS), + ] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + plans = r.json()["plans"] + assert len(plans) == 2 + assert plans[0] == { + "id": "i1", + "title": "Step A", + "campaign_id": "c1", + "status": "planned", + } + assert plans[1] == { + "id": "i2", + "title": "Step B", + "campaign_id": "c2", + "status": "in_progress", + } + + def test_empty_list_for_unknown_session(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/ghost/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] + + def test_calls_store_with_session_id(self): + cs = _mock_cs() + client = TestClient(_make_app(cs)) + + client.get("/api/sessions/sess_abc/plans") + + cs.get_plan_items_for_session.assert_called_once_with("sess_abc") + + def test_graceful_when_no_context_store(self): + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _ServerNoCS: + context_store = None + agent_bridge = None + gently_store = None + + server = _ServerNoCS() + app.include_router(session_router(server)) + client = TestClient(app) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] From 73edb37e09d2da172601d2d4f541b2da54d29081 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:41:44 +0530 Subject: [PATCH 145/200] feat(f): Plans-tab session link/delink controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add [+ link session] picker and per-session [×] delink to the item-detail Sessions section. Sessions rendered from item.session_ids (item-scoped), not the campaign pool. Backend get_item_detail now filters to item.session_ids. Picker: click opens inline select from /api/sessions (already-linked filtered out), Link → POST /api/.../sessions → selectItem re-fetch; Cancel closes. Delink: × → DELETE /api/.../sessions/:sid → selectItem re-fetch. State: _sessionPickerOpen + _availableSessions on state; reset on item switch. CSS: .session-link-btn, .session-delink-btn, .session-picker* styles added. node --check: pass. Chrome-MCP harness: scratchpad/flink/index.html (verified link, delink, picker flow, empty-state, filtered select). --- gently/ui/web/routes/campaigns.py | 8 +- gently/ui/web/static/css/campaigns.css | 103 +++++++++++++++++++ gently/ui/web/static/js/campaigns.js | 133 +++++++++++++++++++++++-- 3 files changed, 234 insertions(+), 10 deletions(-) diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index 857ed2db..7c463c96 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -244,8 +244,12 @@ async def get_item_detail(campaign_id: str, item_id: str): } ) - # Sessions linked to this campaign - sessions = cs.get_sessions_for_campaign(item.campaign_id) + # Sessions — return only those linked to this specific item (item.session_ids), + # not all campaign sessions. The frontend uses item.session_ids as the canonical + # list and this pool as metadata (name, created_at) for display. + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] return { "item": _serialize(item), diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css index 88690dec..b874ab44 100644 --- a/gently/ui/web/static/css/campaigns.css +++ b/gently/ui/web/static/css/campaigns.css @@ -1435,6 +1435,109 @@ font-size: 0.62rem; color: var(--text-muted); } +.detail-session-right { + display: flex; + align-items: center; + gap: 6px; +} +.detail-session-empty { + color: var(--text-muted); + font-size: 0.75rem; + font-style: italic; +} + +/* Session link button (header action) */ +.session-link-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.65rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.session-link-btn:hover { + color: var(--text); + border-color: var(--accent, #2f6df6); +} + +/* Per-session delink button */ +.session-delink-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 1px 4px; + border-radius: 4px; + opacity: 0.45; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.session-delink-btn:hover { + opacity: 1; + color: var(--accent-orange, #f97316); + background: rgba(249,115,22,0.12); +} + +/* Inline session picker */ +.session-picker { + margin-top: 8px; + padding: 10px; + background: rgba(0,0,0,0.18); + border: 1px solid var(--border); + border-radius: 8px; +} +.session-picker--loading { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} +.session-picker-select { + width: 100%; + background: var(--bg-dark, #0f172a); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font: inherit; + font-size: 0.75rem; + margin-bottom: 8px; + box-sizing: border-box; +} +.session-picker-actions { + display: flex; + gap: 6px; +} +.session-picker-link-btn { + flex: 1; + background: var(--accent, #2f6df6); + color: #fff; + border: none; + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.session-picker-link-btn:hover { opacity: 0.85; } +.session-picker-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + cursor: pointer; + transition: color 0.15s; +} +.session-picker-cancel-btn:hover { color: var(--text); } /* ================================================================ SHARED COMPONENTS diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index 6a3c3799..c465e3f9 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -67,6 +67,8 @@ const state = { editingSpec: false, // inspector imaging-spec edit mode _inspectorData: null, // last item-detail payload (for re-render on edit toggle) _specError: '', // inline save error in the spec editor + _sessionPickerOpen: false, // whether the session link picker is visible + _availableSessions: null, // null = not yet loaded, [] = loaded (for link picker) }; // ── DOM refs (cached on init) ──────────────────────────── @@ -141,6 +143,10 @@ function boot() { case 'spec-edit': e.stopPropagation(); startSpecEdit(); break; case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break; case 'spec-save': e.stopPropagation(); saveSpecEdit(); break; + case 'session-picker-open': e.stopPropagation(); openSessionPicker(); break; + case 'session-picker-cancel': e.stopPropagation(); cancelSessionPicker(); break; + case 'session-picker-link': e.stopPropagation(); submitSessionLink(); break; + case 'session-delink': e.stopPropagation(); handleSessionDelink(el.dataset.sessionId); break; } }); @@ -630,6 +636,8 @@ async function selectItem(itemId) { if (itemId !== state.selectedItemId) { state.editingSpec = false; state._specError = ''; + state._sessionPickerOpen = false; + state._availableSessions = null; } state.selectedItemId = itemId; @@ -673,7 +681,13 @@ function renderInspector(data) { const item = data.item; const deps = data.dependencies || []; const dnts = data.dependents || []; - const sessions = data.sessions || []; + // Build a metadata map from the campaign-level sessions included in the payload, + // then derive the per-item sessions list from item.session_ids (the true source + // of truth). data.sessions is the campaign pool; we only show sessions that are + // actually linked to THIS item. + const _sessionMeta = {}; + (data.sessions || []).forEach(s => { _sessionMeta[s.session_id || s.id] = s; }); + const sessions = (item.session_ids || []).map(sid => _sessionMeta[sid] || { session_id: sid, id: sid }); if ($inspectorTitle) $inspectorTitle.textContent = item.title; if ($inspectorStatus) { @@ -786,20 +800,46 @@ function renderInspector(data) { html += section('References', refHtml); } - // Sessions + // Sessions — item-scoped (item.session_ids), with link/delink controls. + const _linkBtn = ``; + let sessHtml = ''; if (sessions.length > 0) { - let sessHtml = ''; sessions.forEach(s => { + const sid = s.session_id || s.id || ''; + const name = s.name || s.planned_intent || sid || 'Session'; sessHtml += `
    - ${esc(s.planned_intent || s.id || 'Session')} - ${s.created_at ? `${formatDate(s.created_at)}` : ''} + ${esc(name)} + + ${s.created_at ? `${formatDate(s.created_at)}` : ''} + +
    `; }); - html += section('Sessions', sessHtml); } else { - html += section('Sessions', - '
    No linked sessions
    '); + sessHtml = '
    No linked sessions
    '; } + // Inline link picker — rendered when openSessionPicker() has set state flag + loaded data. + if (state._sessionPickerOpen) { + if (state._availableSessions === null) { + // Still loading — show spinner text; will re-render once fetch completes. + sessHtml += `
    Loading sessions…
    `; + } else { + const _linkedIds = new Set(item.session_ids || []); + const _available = state._availableSessions.filter(s => !_linkedIds.has(s.session_id)); + const _opts = _available.length === 0 + ? `` + : _available.map(s => ``).join(''); + sessHtml += `
    + +
    + + +
    +
    `; + } + } + html += section('Sessions', sessHtml, _linkBtn); if ($inspectorBody) $inspectorBody.innerHTML = html; } @@ -902,6 +942,83 @@ async function saveSpecEdit() { } } +// ── Session link / delink ───────────────────────────────────────────────────── + +// Open the inline session picker. Fetches /api/sessions and re-renders with the +// picker shown. Two-phase: immediate re-render with loading state, then again +// once the fetch resolves (mirrors the pattern of selectItem loading state). +async function openSessionPicker() { + state._sessionPickerOpen = true; + state._availableSessions = null; // triggers loading display + if (state._inspectorData) renderInspector(state._inspectorData); + try { + const res = await fetch('/api/sessions'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + state._availableSessions = body.sessions || []; + } catch (err) { + console.error('Failed to load sessions for picker:', err); + state._availableSessions = []; + } + if (state._sessionPickerOpen && state._inspectorData) { + renderInspector(state._inspectorData); + } +} + +function cancelSessionPicker() { + state._sessionPickerOpen = false; + state._availableSessions = null; + if (state._inspectorData) renderInspector(state._inspectorData); +} + +// Read the picker ${opts} +
    + + +
    +
    `; + } + } + + panelEl.innerHTML = html; + + // Wire events directly on the rendered buttons. + const sid = sessionId; + const linkBtn = panelEl.querySelector('#ops-lp-link-btn'); + if (linkBtn) { + linkBtn.addEventListener('click', () => + this._openPlanPickerInPanel(panelEl, plans, campaignNameMap, sid)); + } + panelEl.querySelectorAll('.ops-lp-delink').forEach(btn => { + btn.addEventListener('click', () => + this._delinkPlanItem(panelEl, btn.dataset.itemId, btn.dataset.campaignId, sid)); + }); + const submitBtn = panelEl.querySelector('#ops-lp-picker-link'); + if (submitBtn) { + submitBtn.addEventListener('click', () => this._submitPlanLink(panelEl, sid)); + } + const cancelBtn = panelEl.querySelector('#ops-lp-picker-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + this._planPickerOpen = false; + this._pickerItems = null; + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid); + }); + } + }, + + // Open the inline plan-item picker: set loading state, fetch /api/campaigns, + // flatten to items, re-render with the picker populated. + async _openPlanPickerInPanel(panelEl, plans, campaignNameMap, sessionId) { + this._planPickerOpen = true; + this._pickerItems = null; // show loading + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + try { + const res = await fetch('/api/campaigns', { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + this._pickerItems = this._flattenCampaignItems(data.campaigns || [], campaignNameMap); + } catch (err) { + console.error('[ExperimentOverview] plan picker fetch error:', err); + this._pickerItems = []; + } + if (this._planPickerOpen) { + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } + }, + + // POST the selected plan item → session link, then refetch and re-render. + async _submitPlanLink(panelEl, sessionId) { + const select = panelEl.querySelector('#ops-lp-picker-sel'); + const value = select && select.value; + if (!value || !value.includes('::')) return; + const [campaignId, itemId] = value.split('::'); + if (!campaignId || !itemId) return; + + this._planPickerOpen = false; + this._pickerItems = null; + panelEl.innerHTML = '
    Linking…
    '; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan link error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // DELETE the plan-item → session edge, then refetch and re-render. + async _delinkPlanItem(panelEl, itemId, campaignId, sessionId) { + panelEl.innerHTML = '
    Unlinking…
    '; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions/${encodeURIComponent(sessionId)}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan delink error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // Refetch linked plans + campaigns after a link/delink op, then re-render. + async _refetchLinkedPlans(panelEl, sessionId) { + try { + const [linkedData, campaignsData] = await Promise.all([ + fetch(`/api/sessions/${encodeURIComponent(sessionId)}/plans`, { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { plans: [] }) + .catch(() => ({ plans: [] })), + fetch('/api/campaigns', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { campaigns: [] }) + .catch(() => ({ campaigns: [] })), + ]); + const plans = linkedData.plans || []; + const campaignNameMap = {}; + this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap); + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } catch (e) { + console.warn('[ExperimentOverview] linked-plans refetch error:', e); + panelEl.innerHTML = '
    Could not reload linked plans.
    '; + } + }, + + _renderRulesView(root, s) { // Compact header echoing the session identity From e2b7e48d0f85b93bf0b9a1a8ad656a720ec51c19 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:11:01 +0530 Subject: [PATCH 147/200] fix(f): item-scoped POST response + delink-failure re-render + defensive split + back-compat test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST link_session_to_item now re-fetches the item and filters get_sessions_for_campaign by item.session_ids — identical scope to GET get_item_detail (contract consistency). handleSessionDelink catch block calls renderInspector to restore UI on failure. experiment-overview.js picker split limited to 2 parts for defensive id parsing. Back-compat store tests: legacy scalar-only session_id found by reverse query and correctly removed by unlink_plan_item_session. --- gently/ui/web/routes/campaigns.py | 7 ++- gently/ui/web/static/js/campaigns.js | 1 + .../ui/web/static/js/experiment-overview.js | 2 +- tests/test_session_plan_linking_routes.py | 12 +++++- tests/test_session_plan_linking_store.py | 43 +++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index 7c463c96..4d9ee071 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -275,7 +275,12 @@ async def link_session_to_item(campaign_id: str, item_id: str, request: Request) cs.link_plan_item_session(item_id, session_id) cs.link_session_campaign(session_id, campaign.id) - sessions = cs.get_sessions_for_campaign(item.campaign_id) + # Re-fetch item so session_ids reflects the just-added link; then filter + # exactly as get_item_detail does — POST and GET return the same scope. + item = cs.get_plan_item(item_id) + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] return {"sessions": [_serialize(s) for s in sessions]} @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}") diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index c465e3f9..d42cdbd4 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -1016,6 +1016,7 @@ async function handleSessionDelink(sessionId) { selectItem(item.id).catch(() => {}); } catch (err) { console.error('Failed to delink session:', err); + renderInspector(state._inspectorData); } } diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 7f97941b..ce59a4a8 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -434,7 +434,7 @@ const ExperimentOverview = { const select = panelEl.querySelector('#ops-lp-picker-sel'); const value = select && select.value; if (!value || !value.includes('::')) return; - const [campaignId, itemId] = value.split('::'); + const [campaignId, itemId] = value.split('::', 2); if (!campaignId || !itemId) return; this._planPickerOpen = false; diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py index b9542d4a..fce5fe52 100644 --- a/tests/test_session_plan_linking_routes.py +++ b/tests/test_session_plan_linking_routes.py @@ -103,7 +103,17 @@ def test_returns_sessions_list(self): assert isinstance(body["sessions"], list) def test_returns_serialized_session_intents(self): - item = _make_item("i1", "c1", "Step A") + # Item must have session_ids=["s1"] to simulate the post-link state + # (mock link_plan_item_session doesn't mutate the item; we configure + # get_plan_item to return the post-link view so the item-scoped filter works). + item = PlanItem( + id="i1", + campaign_id="c1", + type=PlanItemType.BENCH, + title="Step A", + status=PlanItemStatus.PLANNED, + session_ids=["s1"], + ) si = SessionIntent( session_id="s1", campaign_ids=["c1"], diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py index 5cd72466..9158168c 100644 --- a/tests/test_session_plan_linking_store.py +++ b/tests/test_session_plan_linking_store.py @@ -165,3 +165,46 @@ def test_back_compat_set_to_most_recent_remaining(self, two_campaigns): assert sid2 in item.session_ids assert item.session_id == sid2 # most recent remaining assert sid1 not in item.session_ids + + +class TestBackCompatLegacyScalar: + """Back-compat path: items written before session_ids list existed (scalar only).""" + + def _inject_legacy_item(self, store, campaign_id: str, item_id: str, session_id: str): + """Overwrite a plan item's YAML record to simulate legacy format: + session_id set but session_ids absent/empty — as written by pre-list code.""" + loc = store._find_plan_item_location(item_id) + assert loc is not None, "item must exist before injecting legacy format" + cid, items, idx = loc + items[idx]["session_id"] = session_id + items[idx]["session_ids"] = [] # empty list = pre-list legacy state + store._write_plan_items(cid, items) + + def test_legacy_scalar_found_by_reverse_query(self, two_campaigns): + """get_plan_items_for_session finds an item with only scalar session_id set.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy" + + # Inject legacy-format record (scalar session_id, empty session_ids) + self._inject_legacy_item(store, cid1, item1, session_id) + + items = store.get_plan_items_for_session(session_id) + assert any(it.id == item1 for it in items), ( + "expected item1 in results for legacy scalar session_id" + ) + + def test_legacy_scalar_unlinked_by_unlink(self, two_campaigns): + """unlink_plan_item_session removes a legacy-scalar item and returns True.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy2" + + self._inject_legacy_item(store, cid1, item1, session_id) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + # Must no longer appear in reverse query + remaining = store.get_plan_items_for_session(session_id) + assert not any(it.id == item1 for it in remaining), ( + "item1 should be removed after unlinking legacy scalar session" + ) From 99540bd039a5f7ad7b832328fa6371d6ed30ede8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:13:00 +0530 Subject: [PATCH 148/200] docs: add F + B2 to the wrap-up PR plan stack --- docs/superpowers/PR-PLAN.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md index aeb217d3..042c5e5c 100644 --- a/docs/superpowers/PR-PLAN.md +++ b/docs/superpowers/PR-PLAN.md @@ -14,7 +14,9 @@ Each PR diffs cleanly against its parent in the stack; integrate the chain when | 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes | | 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited | | 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests | -| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) | +| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | 4 tasks + whole-branch review + fix; 54 tests; Chrome-audited | +| 7 | `feature/session-plan-linking` (F) | Session↔**plans** link/delink: multi-plan model (`unlink_plan_item_session` + reverse-query), link/delink endpoints, Plans-tab controls + session Linked-plans panel | D2 | 4 tasks + whole-branch review + fix; ~70 tests; both surfaces Chrome-audited | +| 8 | `feature/manual-mode-dual-camera` (B2) | Dual-camera config + laser-preset browser + timelapse config form (extends B1 manual mode) | F | TBD (SDD) | Notes: - Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes) From 1f743cebb82b5c0f190cc5c25f2f2d5d2c428314 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:18:24 +0530 Subject: [PATCH 149/200] docs(b2): dual-camera + laser-preset + timelapse-form spec + plan (3 tasks; live parts rig-deferred) --- .../2026-06-29-manual-mode-dual-camera.md | 38 +++++++++++ ...26-06-29-manual-mode-dual-camera-design.md | 63 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md create mode 100644 docs/superpowers/specs/2026-06-29-manual-mode-dual-camera-design.md diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md new file mode 100644 index 00000000..96e4c141 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md @@ -0,0 +1,38 @@ +# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred). + +**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`. + +## Global Constraints +- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware. +- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact. +- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI. +- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones. +- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Laser-preset browser +**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `` populated from `GET /api/devices/laser/configs`; on change POST the + chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't + remove the I3 guard — selecting a preset is an explicit user action). +- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig). + +## 2. Dual-camera config +- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in + `device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log + otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` + + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and + restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras` + endpoint listing available camera roles (A always; B if registered). +- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST. +- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition + (two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side. + +## 3. Timelapse config form +- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the + agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value, + monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume + geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through + / persisted to per-embryo calibration where applicable. +- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/ + monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` + for defaults. A "Start timelapse" submit → the new proxy. +- **Rig-deferred:** the actual timelapse run + galvo/piezo motion. + +## 4. Out of scope / deferred +- Live Multi-Camera dual view + dual-side acquisition demux (rig). +- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later). +- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply). + +## 5. Testing +- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check` + + Chrome audit of the dropdown populated from a stubbed configs endpoint. +- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when + absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists + roles; `node --check` + Chrome audit of the side selector. +- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the + params; `node --check` + Chrome audit of the form. +- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact). From 340ab96811dc7715f8712171b151c5734aede2b2 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:24:20 +0530 Subject: [PATCH 150/200] feat(b2): laser-preset browser (set proxy + manual-mode dropdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/devices/laser/config proxy (require_control, 400 on missing/empty config, mirrors /laser/off client+error pattern). Add — populated on manual-view entry let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -201,6 +202,7 @@ const DevicesManager = (function () { _lsLastcap = document.getElementById('devices-ls-lastcap'); _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); _lsLaserStatus = document.getElementById('devices-ls-laser-status'); + _lsLaserPreset = document.getElementById('devices-laser-preset'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1365,6 +1367,59 @@ const DevicesManager = (function () { } } + /** Fetch laser config-group presets and populate the #devices-laser-preset select. + * Selects "ALL OFF" by default (entry safety preset). + * Wires the change handler to POST the selected preset. + * Fire-and-forget safe — failure leaves the fallback "ALL OFF" option in place. */ + async function populateLaserPresets() { + cacheDom(); + if (!_lsLaserPreset) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + // data may be an array of preset names or {configs: [...]} + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + // Rebuild option list + _lsLaserPreset.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _lsLaserPreset.appendChild(opt); + } + // Default to "ALL OFF" — entry safety state + if (presets.includes('ALL OFF')) _lsLaserPreset.value = 'ALL OFF'; + // Wire change handler (idempotent — replace via re-assignment) + _lsLaserPreset.onchange = () => setLaserPreset(_lsLaserPreset.value); + } catch (err) { + console.debug('laser preset fetch failed:', err); + } + } + + /** POST a named laser preset to the device layer. + * Updates the status indicator on success. + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserPreset(config) { + cacheDom(); + if (!config) return; + try { + const res = await fetch('/api/devices/laser/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? config : 'warning: state unknown'; + } + if (!res.ok) console.debug('laser preset set failed:', await res.text()); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser preset set failed:', err); + } + } + async function toggleLightsheetStream() { if (!_lsToggle) return; _lsToggle.disabled = true; @@ -1972,7 +2027,9 @@ const DevicesManager = (function () { } } // Entering Manual view — gate lasers off immediately (brightfield-safe). - if (viewName === 'manual') setLaserOff(); + // populateLaserPresets() runs after setLaserOff() so the select is always + // seeded with the entry-safety state first. + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1f09ba06..c491eea3 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -797,6 +797,13 @@

    Properties

    +
    + + +
    Laser: OFF (brightfield) diff --git a/tests/test_laser_preset_route.py b/tests/test_laser_preset_route.py new file mode 100644 index 00000000..ba6f4538 --- /dev/null +++ b/tests/test_laser_preset_route.py @@ -0,0 +1,127 @@ +"""Tests for B2 Task 1 — POST /api/devices/laser/config (set-preset proxy). + +Verifies: + - POST /api/devices/laser/config calls client.set_laser_config(config) + - Missing config body key → 400 + - Empty string config → 400 + - No client (microscope not connected) → 503 + - require_control gate (403 without override) +""" +from unittest.mock import MagicMock, AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.data import create_router +import gently.ui.web.auth as auth + + +def _app(client=None): + """Build a TestClient with the data routes wired up. + + Overrides require_control so the TestClient always gets the control role — + isolates route logic from auth, mirroring the pattern in test_lightsheet_routes. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +def test_set_laser_config_calls_client(): + """POST /api/devices/laser/config must call client.set_laser_config(config).""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "488 only"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("488 only") + + +def test_set_laser_config_all_off(): + """POST /api/devices/laser/config works with 'ALL OFF'.""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# 400 — missing / invalid config +# --------------------------------------------------------------------------- + +def test_set_laser_config_missing_key_returns_400(): + """POST without 'config' key in body → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"wrong_key": "ALL OFF"}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_empty_string_returns_400(): + """POST with empty string config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": ""}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_null_returns_400(): + """POST with null config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": None}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — no client +# --------------------------------------------------------------------------- + +def test_set_laser_config_no_client_returns_503(): + """POST when microscope not connected → 503.""" + r = _app(client=None).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + +def test_set_laser_config_requires_control(): + """POST /api/devices/laser/config is gated by require_control.""" + server = MagicMock() + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 403 From 4ba1d05e4d3652f7c307d092d0618611baac7459 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:38:25 +0530 Subject: [PATCH 151/200] feat(b2): dual-camera config (HamCam2 + side selector) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register DiSPIMCamera("HamCam2") as devices["camera_b"] defensively in device_factory — only when HamCam2 is in core.getLoadedDevices(); single- camera rigs skip and log a warning. Add "side" ('A'|'B', default 'A') to _ls_params; _ensure_lightsheet_sequence_sync selects camera/camera_b by side and restarts the sequence on side change (mirrors the exposure-change restart path). Side B without camera_b falls back to A. New GET /api/cameras endpoint (device-layer + UI proxy) lists available camera roles. UI: #devices-ls-side select in the manual rail, hidden on single-camera rigs, shown when GET /api/devices/cameras lists 'B'. postLightsheetParams includes side on every call. populateCameraRoles() called on manual-view entry alongside populateLaserPresets. RIG-DEFERRED: live Multi-Camera dual view + dual-side acquisition. TDD: 16 tests, all pass; node --check exit 0; no new suite failures. --- gently/hardware/dispim/client.py | 15 +- gently/hardware/dispim/device_factory.py | 21 +++ gently/hardware/dispim/device_layer.py | 47 ++++- gently/ui/web/routes/data.py | 19 +- gently/ui/web/static/js/devices.js | 32 +++- gently/ui/web/templates/index.html | 10 + tests/test_dual_camera_factory.py | 107 +++++++++++ tests/test_lightsheet_side_param.py | 223 +++++++++++++++++++++++ 8 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 tests/test_dual_camera_factory.py create mode 100644 tests/test_lightsheet_side_param.py diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 1437a8a8..b4b38557 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -834,6 +834,15 @@ async def get_laser_configs(self) -> dict: """ return await self._api_get("/api/laser/configs") + async def get_cameras(self) -> dict: + """List available SPIM camera roles. + + Hits ``GET /api/cameras`` — returns ``{"cameras": ["A"]}`` on + single-camera rigs or ``{"cameras": ["A", "B"]}`` when HamCam2 is + registered as camera_b in the device layer. + """ + return await self._api_get("/api/cameras") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") @@ -1015,9 +1024,9 @@ async def stream_lightsheet(self, timeout: float | None = None): logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) async def set_lightsheet_live_params( - self, galvo=None, piezo=None, exposure=None + self, galvo=None, piezo=None, exposure=None, side=None ) -> dict: - """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + """POST live galvo/piezo/exposure/side to the device-layer lightsheet streamer.""" body = {} if galvo is not None: body["galvo"] = float(galvo) @@ -1025,6 +1034,8 @@ async def set_lightsheet_live_params( body["piezo"] = float(piezo) if exposure is not None: body["exposure"] = float(exposure) + if side is not None: + body["side"] = str(side) return await self._api_post("/api/lightsheet/live/params", body) async def set_camera_led_mode(self, use_led: bool = False) -> dict: diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index e914b1b1..96b0d276 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -64,6 +64,7 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non default_config = { "xy_stage_name": "XYStage:XY:31", "camera_name": "HamCam1", + "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", "fdrive_name": "ZStage:V:37", @@ -108,6 +109,26 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera: %s", e) + # Defensively register the second SPIM camera (side B) only when present + # in the core's loaded-device list. Single-camera rigs that lack HamCam2 + # continue to start normally; camera_b is simply absent from devices. + try: + from .devices import DiSPIMCamera as _DiSPIMCamera + + cam_b_name = cfg.get("camera_b_name", "HamCam2") + loaded_devices = list(core.getLoadedDevices()) + if cam_b_name in loaded_devices: + camera_b = _DiSPIMCamera(device_name=cam_b_name, core=core) + devices["camera_b"] = camera_b + logger.info("Created camera_b (side B): %s", cam_b_name) + else: + logger.warning( + "camera_b (%s) not in loaded devices — single-camera rig or device absent; skipping", + cam_b_name, + ) + except Exception as e: + logger.warning("Could not create camera_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0e44ebfa..69b51581 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -174,7 +174,7 @@ def __init__( self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows self._ls_target_max_dim: int = 512 self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} + self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values @@ -1016,15 +1016,33 @@ def _park_lightsheet_sync(self) -> None: self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + """Start (or restart on exposure/side change) the continuous sequence on the SPIM camera. + + Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] + (HamCam2) when registered. If side B is requested but camera_b is absent, + falls back to side A and logs a warning — single-camera rigs are unaffected. + """ core = self.system.core - cam = self.devices.get("camera") + p = self._ls_params + side = p.get("side", "A") + + # Resolve the camera for the requested side + if side == "B" and "camera_b" in self.devices: + cam = self.devices.get("camera_b") + else: + if side == "B": + logger.warning( + "Side B requested but camera_b not registered; falling back to side A" + ) + cam = self.devices.get("camera") + if cam is None: raise RuntimeError("No lightsheet camera configured") - p = self._ls_params + need_restart = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] + or self._ls_applied.get("side") != side ) if need_restart: if core.isSequenceRunning(): @@ -1035,6 +1053,7 @@ def _ensure_lightsheet_sequence_sync(self) -> None: core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) self._ls_seq_started = True self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): """Park → ensure sequence running → peek the latest frame (never drain).""" @@ -1167,15 +1186,17 @@ async def handle_lightsheet_stream(self, request): return response async def handle_lightsheet_params(self, request): - """POST /api/lightsheet/live/params — update live galvo/piezo/exposure. + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure/side. - Body: {"galvo": float, "piezo": float, "exposure": float} (all optional). - Galvo/piezo apply on the next grab; exposure change triggers sequence restart. + Body: {"galvo": float, "piezo": float, "exposure": float, "side": "A"|"B"} (all optional). + Galvo/piezo apply on the next grab; exposure or side change triggers sequence restart. """ body = await request.json() for k in ("galvo", "piezo", "exposure"): if k in body and body[k] is not None: self._ls_params[k] = float(body[k]) + if "side" in body and body["side"] in ("A", "B"): + self._ls_params["side"] = body["side"] return web.json_response({"params": self._ls_params}) # ========================================================================= @@ -2084,6 +2105,17 @@ async def handle_get_laser_configs(self, request): status=500, ) + async def handle_get_cameras(self, request): + """GET /api/cameras — list available SPIM camera roles. + + Returns ``{"cameras": ["A"]}`` on single-camera rigs, or + ``{"cameras": ["A", "B"]}`` when camera_b (HamCam2) is registered. + """ + cameras = ["A"] + if "camera_b" in self.devices: + cameras.append("B") + return web.json_response({"cameras": cameras}) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -3100,6 +3132,7 @@ async def on_start(self): # Lightsheet (SPIM) live stream — continuous sequence acquisition self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) + self._app.router.add_get("/api/cameras", self.handle_get_cameras) # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 8897b292..80ce9fd0 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -476,14 +476,14 @@ async def stop_lightsheet_live_stream(): @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 - """Forward galvo/piezo/exposure params to the device-layer lightsheet streamer.""" + """Forward galvo/piezo/exposure/side params to the device-layer lightsheet streamer.""" client = _resolve_client() if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") try: res = await client.set_lightsheet_live_params( galvo=payload.get("galvo"), piezo=payload.get("piezo"), - exposure=payload.get("exposure")) + exposure=payload.get("exposure"), side=payload.get("side")) except Exception as exc: logger.exception("lightsheet live params failed") raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc @@ -560,6 +560,21 @@ async def laser_config_set(payload: dict = Body(...)): # noqa: B008 logger.exception("Laser config set command failed") raise HTTPException(status_code=502, detail=f"laser config failed: {exc}") from exc + @router.get("/api/devices/cameras") + async def cameras_list(): + """Return the available SPIM camera roles (A always; B if camera_b registered). + + No require_control — read-only status route, mirrors GET /api/devices/laser/configs. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_cameras() + except Exception as exc: + logger.exception("Cameras list fetch failed") + raise HTTPException(status_code=502, detail=f"cameras failed: {exc}") from exc + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}.""" diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index fad43df7..a5ce39dd 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -91,6 +91,7 @@ const DevicesManager = (function () { let _lsGalvo = 0; let _lsPiezo = 50; let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) + let _lsSide = 'A'; // SPIM side — 'A' (HamCam1) or 'B' (HamCam2 if present) let _lsParamTimer = null; // Lightsheet control inputs (rail) @@ -100,6 +101,7 @@ const DevicesManager = (function () { let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsLaserPreset; // — shown only when camera_b present let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -203,6 +205,7 @@ const DevicesManager = (function () { _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); _lsLaserStatus = document.getElementById('devices-ls-laser-status'); _lsLaserPreset = document.getElementById('devices-laser-preset'); + _lsSideSelect = document.getElementById('devices-ls-side'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1398,6 +1401,31 @@ const DevicesManager = (function () { } } + /** Fetch SPIM camera roles and show the Side A/B selector if camera_b is present. + * Called on manual-view entry. Hides the selector on single-camera rigs. + * Fire-and-forget safe — failure leaves the selector hidden (safe default). */ + async function populateCameraRoles() { + cacheDom(); + const group = document.getElementById('devices-ls-side-group'); + try { + const res = await fetch('/api/devices/cameras'); + if (!res.ok) return; + const data = await res.json(); + // data may be {cameras: [...]} or a raw array + const cameras = Array.isArray(data) ? data : (data.cameras || []); + const hasSideB = cameras.includes('B'); + if (group) group.style.display = hasSideB ? '' : 'none'; + if (_lsSideSelect && hasSideB) { + _lsSideSelect.onchange = () => { + _lsSide = _lsSideSelect.value; + postLightsheetParams(); + }; + } + } catch (err) { + console.debug('camera roles fetch failed:', err); + } + } + /** POST a named laser preset to the device layer. * Updates the status indicator on success. * Fire-and-forget safe — failure shows a warning, never throws. */ @@ -1607,7 +1635,7 @@ const DevicesManager = (function () { fetch('/api/devices/lightsheet/live/params', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }), + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure, side: _lsSide }), }).catch(err => console.debug('lightsheet params post failed:', err)); } @@ -2029,7 +2057,7 @@ const DevicesManager = (function () { // Entering Manual view — gate lasers off immediately (brightfield-safe). // populateLaserPresets() runs after setLaserOff() so the select is always // seeded with the entry-safety state first. - if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); } + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index c491eea3..3000bf09 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -758,6 +758,16 @@

    Properties

    + + +
    diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py new file mode 100644 index 00000000..5476184e --- /dev/null +++ b/tests/test_dual_camera_factory.py @@ -0,0 +1,107 @@ +"""Tests for B2 Task 2 — dual-camera device_factory (HamCam2 defensive registration). + +Verifies: + - FakeCore with "HamCam2" in getLoadedDevices() → devices["camera_b"] created (name == "HamCam2") + - FakeCore without "HamCam2" → camera_b absent, no crash, devices["camera"] still created + - camera_b registration uses core.getLoadedDevices() as the presence check +""" +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_factory. +# All device classes use pymmcore / ophyd.status only (no ophyd base classes), +# so patching at the module level is sufficient. +for _mod in ( + "pymmcore", + "ophyd", + "ophyd.status", + "bluesky", + "bluesky.run_engine", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_factory import create_devices_from_mmcore # noqa: E402 + + +class FakeCore: + """Minimal CMMCore stand-in with configurable loaded-device list.""" + + def __init__(self, loaded_devices=None): + self._loaded = list(loaded_devices or []) + + def getLoadedDevices(self): + return self._loaded + + # stubs that DiSPIMCamera.__init__ may indirectly trigger via ophyd proxies + def getProperty(self, dev, prop): + return "0" + + def setProperty(self, dev, prop, val): + pass + + def getCameraDevice(self): + return "" + + def setCameraDevice(self, name): + pass + + +# --------------------------------------------------------------------------- +# Happy path — HamCam2 present +# --------------------------------------------------------------------------- + + +def test_camera_b_created_when_hamcam2_loaded(): + """HamCam2 in getLoadedDevices → devices['camera_b'] created with name 'HamCam2'.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" in devices, "camera_b must be registered when HamCam2 is in loaded devices" + assert devices["camera_b"].name == "HamCam2" + + +def test_camera_b_name_is_hamcam2(): + """The camera_b name attribute is exactly the configured camera_b_name.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:AB:33"]) + devices = create_devices_from_mmcore(core) + assert devices["camera_b"].name == "HamCam2" + + +# --------------------------------------------------------------------------- +# Single-camera rig — HamCam2 absent +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_when_hamcam2_not_loaded(): + """HamCam2 NOT in getLoadedDevices → camera_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices, "camera_b must be absent on single-camera rigs" + + +def test_camera_still_created_without_hamcam2(): + """Primary camera (HamCam1) is created regardless of HamCam2 presence.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera" in devices + assert devices["camera"].name == "HamCam1" + + +# --------------------------------------------------------------------------- +# Edge case — empty loaded-device list +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_on_empty_device_list(): + """Empty loaded-device list → camera_b not attempted, no extra error.""" + core = FakeCore(loaded_devices=[]) + # DiSPIMCamera.__init__ only stores name/core; it succeeds even with no devices. + # So "camera" is still created; "camera_b" is skipped. + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices + assert "camera" in devices diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py new file mode 100644 index 00000000..25009334 --- /dev/null +++ b/tests/test_lightsheet_side_param.py @@ -0,0 +1,223 @@ +"""Tests for B2 Task 2 — lightsheet side param (handle_lightsheet_params + +_ensure_lightsheet_sequence_sync + handle_get_cameras). + +Verifies: + - handle_lightsheet_params accepts 'side' ('A'|'B'); invalid values ignored + - side change in _ensure_lightsheet_sequence_sync selects the correct camera + and triggers a sequence restart (mirroring the exposure-change path) + - side B without camera_b falls back to A + no crash + - handle_get_cameras returns ["A"] or ["A","B"] based on devices dict +""" +import json +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeCore: + """Minimal CMMCore stub tracking sequence-start/stop calls.""" + + def __init__(self): + self.running = False + self.cam = None + self.started = 0 + self.stopped = 0 + self.exposure = None + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + +def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + cam_a = type("C", (), {"name": "HamCam1"})() + cam_b = type("C", (), {"name": "HamCam2"})() + dl.devices = {"camera": cam_a} + if with_camera_b: + dl.devices["camera_b"] = cam_b + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# _ensure_lightsheet_sequence_sync — camera selection by side +# --------------------------------------------------------------------------- + + +def test_side_a_selects_camera_a(): + """Side A → sequence started on HamCam1.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_b_selects_camera_b(): + """Side B with camera_b present → sequence started on HamCam2.""" + dl = _make_dl(with_camera_b=True) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam2" + assert dl.system.core.started == 1 + + +def test_side_b_fallback_to_a_when_no_camera_b(): + """Side B without camera_b → graceful fallback to A (HamCam1), no crash.""" + dl = _make_dl(with_camera_b=False) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_change_triggers_restart(): + """Switching from A to B stops the running sequence and restarts on HamCam2.""" + dl = _make_dl(with_camera_b=True) + # First call — start on A + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + assert dl.system.core.cam == "HamCam1" + + # Switch to B — must restart + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.stopped >= 1 + assert dl.system.core.started >= 2 + assert dl.system.core.cam == "HamCam2" + + +def test_no_restart_when_side_unchanged(): + """Same side, same exposure → sequence already running, no extra restart.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + starts_after_first = dl.system.core.started + + # Manually mark the applied state (as the real code does) + dl._ls_applied["exposure"] = 20.0 + dl._ls_applied["side"] = "A" + + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == starts_after_first + + +# --------------------------------------------------------------------------- +# handle_lightsheet_params — side accepted / validated +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_b(): + """POST {side: 'B'} → _ls_params['side'] updated to 'B'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "B" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_a(): + """POST {side: 'A'} when already A → _ls_params['side'] stays 'A'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "A"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_ignores_invalid_side(): + """Invalid side value (not 'A'|'B') → _ls_params['side'] unchanged.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + req = MagicMock() + req.json = AsyncMock(return_value={"side": "C"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_exposure_and_side(): + """POST with both exposure and side → both applied.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"exposure": 50.0, "side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["exposure"] == 50.0 + assert dl._ls_params["side"] == "B" + + +# --------------------------------------------------------------------------- +# handle_get_cameras — roles endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_get_cameras_with_camera_b(): + """Both A and B listed when camera_b is registered.""" + dl = _make_dl(with_camera_b=True) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_handle_get_cameras_without_camera_b(): + """Only A listed when camera_b is absent.""" + dl = _make_dl(with_camera_b=False) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A"] From 5f7011e7cf62219264497b80566678ffd3fa1615 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:51:07 +0530 Subject: [PATCH 152/200] feat(b2): manual timelapse-config form (start proxy + rail panel) Adds POST /api/devices/timelapse/start (require_control) that validates params and calls orchestrator.start via server.agent_bridge.agent.timelapse_orchestrator. Adds a collapsible Timelapse panel to #devices-view-manual with cadence, stop condition, embryo IDs, monitoring mode, and volume geometry fields. devices.js populates defaults from GET /api/devices/scan_geometry + /api/devices/laser/configs on manual-view entry and POSTs on submit. 16 TDD tests; node --check passes; galvo/piezo motion is rig-deferred. --- gently/ui/web/routes/data.py | 112 +++++++++++ gently/ui/web/static/js/devices.js | 168 ++++++++++++++++- gently/ui/web/templates/index.html | 130 +++++++++++++ tests/test_timelapse_start_route.py | 275 ++++++++++++++++++++++++++++ 4 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 tests/test_timelapse_start_route.py diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 80ce9fd0..8342fe09 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -671,6 +671,118 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + # ------------------------------------------------------------------ + # Timelapse + # ------------------------------------------------------------------ + + @router.post("/api/devices/timelapse/start", dependencies=[Depends(require_control)]) + async def timelapse_start(payload: dict = Body(...)): # noqa: B008 + """Start an adaptive timelapse from the manual UI. + + Body fields (all optional except interval_seconds has a default): + interval_seconds (float, default 120) — cadence; must be > 0 + stop_condition (str, default "manual") — "manual", "timepoints", "duration" + embryo_ids (list[str] | null) — null = all active embryos + condition_value (int | null) — timepoints count or duration hours + monitoring_mode (str | null) — "idle" / "expression_monitoring" / + "pre_terminal_monitoring" + num_slices (int, default 50) — must be >= 1 if provided + exposure_ms (float, default 10.0) + galvo_amplitude (float, default 0.5) + galvo_center (float, default 0.0) + piezo_amplitude (float, default 25.0) + piezo_center (float, default 50.0) + laser_config (str | null) + + Validation: + - interval_seconds must be > 0 + - num_slices must be >= 1 + + Orchestrator access: server.agent_bridge.agent.timelapse_orchestrator + RIG-DEFERRED: the actual acquisition + galvo/piezo motion. + """ + # --- Validate --- + raw_interval = payload.get("interval_seconds", 120.0) + try: + interval_seconds = float(raw_interval) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="interval_seconds must be a number") + if interval_seconds <= 0: + raise HTTPException(status_code=400, detail="interval_seconds must be > 0") + + raw_slices = payload.get("num_slices") + if raw_slices is not None: + try: + num_slices = int(raw_slices) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="num_slices must be an integer") + if num_slices < 1: + raise HTTPException(status_code=400, detail="num_slices must be >= 1") + else: + num_slices = 50 + + stop_condition = str(payload.get("stop_condition") or "manual") + embryo_ids = payload.get("embryo_ids") or None + condition_value = payload.get("condition_value") + monitoring_mode = payload.get("monitoring_mode") or None + + # Volume geometry — passed through for context / future calibration write; + # not forwarded to orchestrator.start (which owns its own geometry via the + # per-embryo calibration). RIG-DEFERRED: real acquisition uses these. + volume_geometry = { + "num_slices": num_slices, + "exposure_ms": float(payload.get("exposure_ms", 10.0)), + "galvo_amplitude": float(payload.get("galvo_amplitude", 0.5)), + "galvo_center": float(payload.get("galvo_center", 0.0)), + "piezo_amplitude": float(payload.get("piezo_amplitude", 25.0)), + "piezo_center": float(payload.get("piezo_center", 50.0)), + "laser_config": payload.get("laser_config") or None, + } + + # --- Resolve orchestrator --- + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orchestrator = getattr(agent, "timelapse_orchestrator", None) if agent else None + if orchestrator is None: + raise HTTPException( + status_code=503, + detail="Timelapse orchestrator not initialised (agent not running or no session)", + ) + + # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + try: + result = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=stop_condition, + base_interval_seconds=interval_seconds, + condition_value=condition_value, + ) + except Exception as exc: + logger.exception("Timelapse start failed") + raise HTTPException(status_code=502, detail=f"timelapse start failed: {exc}") from exc + + # Optionally install a monitoring mode at startup (mirrors start_adaptive_timelapse) + mode_result = None + if monitoring_mode and monitoring_mode != "idle": + try: + mode_result = orchestrator.enable_monitoring_mode(monitoring_mode) + except Exception as exc: + mode_result = f"warning: failed to enable monitoring mode: {exc}" + + return { + "started": True, + "result": result, + "monitoring_mode_result": mode_result, + "config": { + "interval_seconds": interval_seconds, + "stop_condition": stop_condition, + "embryo_ids": embryo_ids, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode, + "volume_geometry": volume_geometry, + }, + } + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a5ce39dd..72f1e7b9 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -104,6 +104,13 @@ const DevicesManager = (function () { let _lsSideSelect; // + s +
    + + +
    + + +
    + + + + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + +
    + + + ms +
    + +
    + + + ° +
    + +
    + + + ° +
    + +
    + + + µm +
    + +
    + + + µm +
    + +
    + + +
    +
    + + +
    + +
    + + +
    + + diff --git a/tests/test_timelapse_start_route.py b/tests/test_timelapse_start_route.py new file mode 100644 index 00000000..9f5c3c1b --- /dev/null +++ b/tests/test_timelapse_start_route.py @@ -0,0 +1,275 @@ +"""Tests for B2 Task 3 — POST /api/devices/timelapse/start (manual timelapse proxy). + +Verifies: + - POST with valid params calls orchestrator.start with the right args → 200 + - monitoring_mode is forwarded to orchestrator.enable_monitoring_mode + - interval_seconds <= 0 → 400 + - num_slices < 1 → 400 + - missing interval_seconds uses default 120.0 (no error) + - orchestrator not initialised → 503 + - require_control gate (403 without override) + +Orchestrator access path: + server.agent_bridge.agent.timelapse_orchestrator + (mirroring the `require_timelapse_orchestrator(agent)` helper in harness/tools/helpers.py) +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _app(orchestrator=None): + """Build a TestClient wired with the data routes. + + The mock server exposes `agent_bridge.agent.timelapse_orchestrator`. + `require_control` is overridden so route logic is isolated from auth. + """ + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orchestrator + # Satisfy other routes that call _resolve_client() or store + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +def _make_orchestrator(start_return="Timelapse started."): + """Return a mock orchestrator with an async start and sync enable_monitoring_mode.""" + orch = MagicMock() + orch.start = AsyncMock(return_value=start_return) + orch.enable_monitoring_mode = MagicMock(return_value="Monitoring mode enabled.") + return orch + + +# --------------------------------------------------------------------------- +# Happy path — minimal valid payload +# --------------------------------------------------------------------------- + +def test_timelapse_start_minimal(): + """POST with just interval_seconds calls orchestrator.start; returns 200 with started=True.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 200 + body = r.json() + assert body["started"] is True + orch.start.assert_awaited_once() + + +def test_timelapse_start_calls_start_with_correct_args(): + """orchestrator.start receives the right interval, stop_condition, embryo_ids, condition_value.""" + orch = _make_orchestrator() + _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 60, + "stop_condition": "timepoints", + "embryo_ids": ["e1", "e2"], + "condition_value": 10, + }, + ) + orch.start.assert_awaited_once_with( + embryo_ids=["e1", "e2"], + stop_condition="timepoints", + base_interval_seconds=60.0, + condition_value=10, + ) + + +def test_timelapse_start_uses_default_interval(): + """Omitting interval_seconds uses the default (120.0) without error.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={}, + ) + assert r.status_code == 200 + _, kwargs = orch.start.call_args + assert kwargs["base_interval_seconds"] == 120.0 + + +def test_timelapse_start_result_in_response(): + """The orchestrator.start return value appears in the response.""" + orch = _make_orchestrator(start_return="Timelapse running — 3 embryos.") + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 90}, + ) + assert r.json()["result"] == "Timelapse running — 3 embryos." + + +# --------------------------------------------------------------------------- +# Monitoring mode +# --------------------------------------------------------------------------- + +def test_timelapse_start_enables_monitoring_mode(): + """monitoring_mode != 'idle' triggers enable_monitoring_mode on the orchestrator.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "expression_monitoring"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_called_once_with("expression_monitoring") + assert r.json()["monitoring_mode_result"] == "Monitoring mode enabled." + + +def test_timelapse_start_idle_mode_skips_enable(): + """monitoring_mode='idle' does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "idle"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_not_called() + + +def test_timelapse_start_no_mode_skips_enable(): + """Omitting monitoring_mode does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + _app(orch).post("/api/devices/timelapse/start", json={"interval_seconds": 120}) + orch.enable_monitoring_mode.assert_not_called() + + +# --------------------------------------------------------------------------- +# Volume geometry passed through in response config +# --------------------------------------------------------------------------- + +def test_timelapse_start_volume_geometry_in_config(): + """Volume geometry fields appear in response['config']['volume_geometry'].""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 120, + "num_slices": 80, + "exposure_ms": 15.0, + "galvo_amplitude": 0.7, + "galvo_center": 0.1, + "piezo_amplitude": 30.0, + "piezo_center": 55.0, + "laser_config": "488 only", + }, + ) + assert r.status_code == 200 + vg = r.json()["config"]["volume_geometry"] + assert vg["num_slices"] == 80 + assert vg["exposure_ms"] == 15.0 + assert vg["laser_config"] == "488 only" + + +# --------------------------------------------------------------------------- +# 400 — validation failures +# --------------------------------------------------------------------------- + +def test_timelapse_start_interval_zero_returns_400(): + """interval_seconds = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 0}, + ) + assert r.status_code == 400 + assert "interval_seconds" in r.json()["detail"] + + +def test_timelapse_start_negative_interval_returns_400(): + """interval_seconds < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": -10}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_num_slices_zero_returns_400(): + """num_slices = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": 0}, + ) + assert r.status_code == 400 + assert "num_slices" in r.json()["detail"] + + +def test_timelapse_start_num_slices_negative_returns_400(): + """num_slices < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": -5}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_non_numeric_interval_returns_400(): + """Non-numeric interval_seconds → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": "fast"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — orchestrator not reachable +# --------------------------------------------------------------------------- + +def test_timelapse_start_no_orchestrator_returns_503(): + """orchestrator is None (agent not running / no session) → 503.""" + r = _app(orchestrator=None).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +def test_timelapse_start_no_agent_bridge_returns_503(): + """agent_bridge missing entirely → 503.""" + server = MagicMock(spec=[]) # no agent_bridge attribute + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + +def test_timelapse_start_requires_control(): + """POST /api/devices/timelapse/start is gated by require_control (403 without override).""" + orch = _make_orchestrator() + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orch + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 403 From 65525e94878afe723c002cb259aa45cd0ca3dbbf Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 09:04:24 +0530 Subject: [PATCH 153/200] fix(b2): style the timelapse panel (define ls-collapsible/ls-sub-* in app CSS) Add 8 CSS classes to main.css that the timelapse panel (#devices-view-manual) references but were never defined in the app: ls-collapsible-head/body/arrow, ls-sub-row/label/section/section-label, ls-tl-status. Panel was rendering unstyled in the real app; scratchpad harness had masked this with inline CSS. Classes mirror the existing .ls-row/.ls-group/.ls-label pattern (same CSS variables, spacing, dark instrument aesthetic). [hidden] fights prevented via explicit [hidden]{display:none} scoped rules. Verified via Chrome MCP harness loading the real main.css with no harness-local ls-* overrides. Also: TODO comment in timelapse_start route noting UI path skips plan auto-linking. --- gently/ui/web/routes/data.py | 2 + gently/ui/web/static/css/main.css | 90 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 8342fe09..99dd07d8 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -750,6 +750,8 @@ async def timelapse_start(payload: dict = Body(...)): # noqa: B008 ) # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + # TODO: UI-initiated timelapses skip the agent tool's plan auto-linking; + # this is intentional — the agent path wires the plan, this route does not. try: result = await orchestrator.start( embryo_ids=embryo_ids, diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index ff964886..7d3a0f7f 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10843,3 +10843,93 @@ body.modal-open { color: var(--map-ink); word-break: break-all; } + +/* ── Timelapse collapsible panel (B2 Task 3) ─────────────────────────────── */ + +/* Clickable header button (also carries .ls-label for font/color) */ +.ls-collapsible-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +/* ▶/▼ caret — rotates when expanded */ +.ls-collapsible-arrow { + font-size: 0.5rem; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: transform 0.15s; +} + +.ls-collapsible-head[aria-expanded="true"] .ls-collapsible-arrow { + transform: rotate(90deg); +} + +/* Collapsible body — hidden via [hidden] attribute; explicit rule prevents + display overrides from fighting the attribute (mirrors .ls-lastcap[hidden]) */ +.ls-collapsible-body { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin-top: 0.35rem; +} + +.ls-collapsible-body[hidden] { display: none; } + +/* Sub-rows: like .ls-row but slightly indented for the body interior */ +.ls-sub-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding-left: 0.25rem; +} + +/* Sub-label: like .ls-label but fixed-width to align inputs column */ +.ls-sub-label { + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + min-width: 4.5rem; + flex-shrink: 0; +} + +/* Volume-Geometry inset sub-group */ +.ls-sub-section { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.4rem 0.5rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + margin: 0.15rem 0; +} + +/* "VOLUME GEOMETRY" sub-header */ +.ls-sub-section-label { + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--map-ink-mute); + margin-bottom: 0.1rem; +} + +/* Start-result / status line — mono, muted */ +.ls-tl-status { + font-family: 'JetBrains Mono', monospace; + font-size: 0.62rem; + color: var(--map-ink-mute); + padding: 0.2rem 0.25rem; + word-break: break-all; +} + +.ls-tl-status[hidden] { display: none; } From 860f5f2927a7e40807b598824e219467a6925ad6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 10:09:33 +0530 Subject: [PATCH 154/200] fix(manual): single LED toggle + laser on/off toggle (replace two-button LED, add laser on/off) --- gently/ui/web/static/css/main.css | 4 ++ gently/ui/web/static/js/devices.js | 70 ++++++++++++++++++++++++++---- gently/ui/web/templates/index.html | 16 ++++--- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 7d3a0f7f..9215466a 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10725,6 +10725,10 @@ body.modal-open { flex-shrink: 0; } +.ls-laser-dot--on { + background: var(--map-accent); +} + /* Laser preset row (B2) */ .ls-laser-row { display: flex; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 72f1e7b9..2319d9b2 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -96,8 +96,11 @@ const DevicesManager = (function () { // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) let _lsCamLedOn = false; + let _lsLaserToggle; + let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsLaserPreset; // From 225de14f927b778470a519d6cbbf26a3070763e5 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:00:43 +0530 Subject: [PATCH 155/200] feat(operations): pin plan kind/scope/state schema in guidance + normalize in validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPERATION_PLAN_GUIDANCE now enumerates all six kind values with glosses, the scope object shape (three modes), tactic state enum, phase state enum, and a concrete JSON example tactic — replacing vague prose that let a real model produce wrong shapes. _validate_tactics now normalizes before validating: - kind: monitor/reactive→reactive_monitor; timelapse/standing→standing_timelapse; protocol/scripted→scripted_protocol; burst→exclusive_burst; reference→standing_timelapse; unknown→custom - tactic state: running/in_progress→active; pending/queued→planned; complete/completed→done - scope: bare list→{mode:embryos,embryo_ids:[…]}; "global"/"all"→{mode:global}; dict missing mode inferred from role/embryo_ids keys - live.phases[].state: pending/queued→todo; running/in_progress→active; complete/completed→done @tool schema constrained: SKIPPED — ToolParameter.type is a bare string; no items/sub-schema field exists; guidance + normalization cover it. 25 new/updated tests; 0 new failures in full suite. --- gently/app/tools/operation_plan_tools.py | 96 ++++++++++- gently/harness/prompts/templates.py | 43 +++++ tests/test_operation_plan_tool.py | 204 ++++++++++++++++++++++- 3 files changed, 338 insertions(+), 5 deletions(-) diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py index 93998a03..b23664b6 100644 --- a/gently/app/tools/operation_plan_tools.py +++ b/gently/app/tools/operation_plan_tools.py @@ -28,13 +28,83 @@ ) _VALID_STATES = frozenset({"planned", "active", "done", "paused"}) +# Synonym maps — normalize model variation to canonical values +_KIND_SYNONYMS: dict[str, str] = { + "monitor": "reactive_monitor", + "reactive": "reactive_monitor", + "timelapse": "standing_timelapse", + "standing": "standing_timelapse", + "protocol": "scripted_protocol", + "scripted": "scripted_protocol", + "burst": "exclusive_burst", + "reference": "standing_timelapse", +} + +_STATE_SYNONYMS: dict[str, str] = { + "in_progress": "active", + "running": "active", + "pending": "planned", + "queued": "planned", + "complete": "done", + "completed": "done", +} + +_PHASE_STATE_SYNONYMS: dict[str, str] = { + "pending": "todo", + "queued": "todo", + "todo": "todo", + "running": "active", + "in_progress": "active", + "active": "active", + "complete": "done", + "completed": "done", + "done": "done", +} + + +def _normalize_scope(scope: object) -> object: + """Normalize scope to canonical {mode, ...} object form.""" + if isinstance(scope, list): + return {"mode": "embryos", "embryo_ids": scope} + if isinstance(scope, str): + if scope in ("global", "all"): + return {"mode": "global"} + # unknown string — wrap as global fallback + return {"mode": "global"} + if isinstance(scope, dict): + if "mode" not in scope: + scope = dict(scope) + if "role" in scope: + scope["mode"] = "role" + elif "embryo_ids" in scope: + scope["mode"] = "embryos" + return scope + # unexpected type — pass through unchanged so we don't lose data + return scope + + +def _normalize_phases(phases: object) -> object: + """Normalize phase state values inside a phases list.""" + if not isinstance(phases, list): + return phases + result = [] + for phase in phases: + if isinstance(phase, dict) and "state" in phase: + raw = phase["state"] + phase = dict(phase) + phase["state"] = _PHASE_STATE_SYNONYMS.get(raw, raw) + result.append(phase) + return result + def _validate_tactics(tactics: list) -> list[dict]: """Validate and normalise a tactics list. Each tactic must have id, name, kind, state. - Unknown kinds are clamped to 'custom'. - Invalid states raise ValueError. + Kind synonyms are mapped to canonical values; unknown kinds are clamped to + 'custom'. State synonyms are mapped to canonical values; unknown states + raise ValueError. Scope is normalized to {mode, ...} object form. + Phase states inside live.phases are normalized to todo/active/done. """ validated = [] for i, t in enumerate(tactics): @@ -44,20 +114,40 @@ def _validate_tactics(tactics: list) -> list[dict]: if not t.get(required_field): raise ValueError(f"Tactic at index {i} missing required field '{required_field}'") tactic = dict(t) + + # --- normalize kind --- kind = tactic["kind"] + kind = _KIND_SYNONYMS.get(kind, kind) if kind not in _VALID_KINDS: logger.warning( "Tactic '%s' has unknown kind '%s' — clamped to 'custom'", tactic.get("id"), kind, ) - tactic["kind"] = "custom" + kind = "custom" + tactic["kind"] = kind + + # --- normalize tactic state --- state = tactic["state"] + state = _STATE_SYNONYMS.get(state, state) if state not in _VALID_STATES: raise ValueError( f"Tactic '{tactic.get('id')}' has invalid state '{state}'. " f"Must be one of: {sorted(_VALID_STATES)}" ) + tactic["state"] = state + + # --- normalize scope --- + if "scope" in tactic: + tactic["scope"] = _normalize_scope(tactic["scope"]) + + # --- normalize phase states inside live.phases --- + live = tactic.get("live") + if isinstance(live, dict) and "phases" in live: + live = dict(live) + live["phases"] = _normalize_phases(live["phases"]) + tactic["live"] = live + validated.append(tactic) return validated diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index 536064b4..957ec0ad 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -237,6 +237,49 @@ `last_fired`, `new_phase`, …) are merged in by the updater as live telemetry arrives; you can seed them at declaration time if the value is already known. +### Allowed values — use these exact strings (renderer dispatches on them) + +**`kind`** ∈ one of: +| value | use when | +|---|---| +| `standing_timelapse` | continuous / periodic imaging running throughout | +| `reactive_monitor` | armed watcher that fires on a condition (signal, threshold) | +| `scripted_protocol` | fixed sequence of named phases (ramp, hold, recovery, …) | +| `exclusive_burst` | short high-cadence burst that blocks other acquisition | +| `oneshot` | single action (z-stack, snapshot, one-off step) | +| `custom` | anything that doesn't fit the above | + +**`state`** (tactic) ∈ `planned | active | done | paused` +Start every tactic as `"planned"`; advance to `"active"` when it begins, +`"done"` when it finishes, `"paused"` if suspended. + +**`scope`** — always an object with a `mode` key (never a bare list or string): +- `{"mode": "global"}` — applies to every embryo in the session +- `{"mode": "embryos", "embryo_ids": ["E01", "E02"]}` — specific embryo IDs +- `{"mode": "role", "role": "test"}` — all embryos carrying the named role + +**`live.phases[].state`** ∈ `todo | active | done` + +### Minimal tactic example + +```json +{ + "id": "t2", + "name": "Temperature ramp", + "kind": "scripted_protocol", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["E01", "E02"]}, + "rationale": "25 → 16 °C step to trigger stress response", + "live": { + "readouts": [{"label": "setpoint", "value": "25 °C"}], + "phases": [ + {"name": "ramp", "state": "todo", "count": 0, "pips": []}, + {"name": "hold", "state": "todo", "count": 0, "pips": []} + ] + } +} +``` + Re-call `declare_operation_plan` (patch) whenever a tactic's state changes: `"planned"` → `"active"` when you start it, `"active"` → `"done"` when it finishes. This keeps the Operations view in the UI synchronized with reality. diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py index ad6804d6..7f2255b6 100644 --- a/tests/test_operation_plan_tool.py +++ b/tests/test_operation_plan_tool.py @@ -365,7 +365,7 @@ async def test_missing_tactic_id_returns_error_no_write(): @pytest.mark.asyncio async def test_invalid_state_returns_error_no_write(): - """A tactic with an invalid state causes an error; nothing is persisted.""" + """A tactic with a completely unrecognised state causes an error; nothing is persisted.""" from gently.app.tools.operation_plan_tools import declare_operation_plan ctx = _make_context() @@ -374,7 +374,7 @@ async def test_invalid_state_returns_error_no_write(): "id": "t1", "name": "Bad state", "kind": "oneshot", - "state": "running", # not a valid state + "state": "completely_unknown_xyz", # not a valid state and not a known synonym } ] result = await declare_operation_plan( @@ -388,6 +388,206 @@ async def test_invalid_state_returns_error_no_write(): assert ctx["agent"].context_store.calls == [] +# --------------------------------------------------------------------------- +# Normalization tests (Fix 2) +# --------------------------------------------------------------------------- + + +def test_normalize_kind_synonyms(): + """Kind synonyms are mapped to canonical values before validation.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("monitor", "reactive_monitor"), + ("reactive", "reactive_monitor"), + ("timelapse", "standing_timelapse"), + ("standing", "standing_timelapse"), + ("protocol", "scripted_protocol"), + ("scripted", "scripted_protocol"), + ("burst", "exclusive_burst"), + ("reference", "standing_timelapse"), + ] + for raw_kind, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": raw_kind, "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == expected, f"kind '{raw_kind}' should map to '{expected}'" + + +def test_normalize_kind_unknown_to_custom(): + """An unrecognised kind that is not a synonym is clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [{"id": "t1", "name": "T", "kind": "future_kind_v99", "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == "custom" + + +def test_normalize_scope_bare_list(): + """A bare list scope is converted to {mode:'embryos', embryo_ids:[...]}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": ["E01", "E02"], + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + + +def test_normalize_scope_global_string(): + """The string 'global' scope is converted to {mode:'global'}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": "global", + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "global"} + + +def test_normalize_scope_dict_missing_mode_with_embryo_ids(): + """A scope dict with embryo_ids but no mode gets mode:'embryos' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"embryo_ids": ["E01"]}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "embryos" + assert result[0]["scope"]["embryo_ids"] == ["E01"] + + +def test_normalize_scope_dict_missing_mode_with_role(): + """A scope dict with role but no mode gets mode:'role' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"role": "test"}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "role" + assert result[0]["scope"]["role"] == "test" + + +def test_normalize_scope_well_formed_dict_unchanged(): + """A correctly formed scope dict passes through unchanged.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + scope = {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": scope, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == scope + + +def test_normalize_phase_state_pending_to_todo(): + """Phase state 'pending' is normalized to 'todo'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "scripted_protocol", + "state": "planned", + "live": { + "readouts": [], + "phases": [ + {"name": "ramp", "state": "pending", "count": 0, "pips": []}, + {"name": "hold", "state": "running", "count": 0, "pips": []}, + {"name": "done_phase", "state": "completed", "count": 3, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + phases = result[0]["live"]["phases"] + assert phases[0]["state"] == "todo" + assert phases[1]["state"] == "active" + assert phases[2]["state"] == "done" + + +def test_normalize_tactic_state_synonyms(): + """Tactic state synonyms are mapped to canonical values.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("in_progress", "active"), + ("running", "active"), + ("pending", "planned"), + ("queued", "planned"), + ("complete", "done"), + ("completed", "done"), + ] + for raw_state, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": "oneshot", "state": "planned"}] + # Override state directly + tactics[0]["state"] = raw_state + result = _validate_tactics(tactics) + assert result[0]["state"] == expected, ( + f"state '{raw_state}' should map to '{expected}'" + ) + + +def test_normalize_full_scenario(): + """Combined: kind synonym + bare-list scope + pending phase state + running tactic state.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "Temp monitor", + "kind": "monitor", # synonym → reactive_monitor + "state": "in_progress", # synonym → active + "scope": ["E01", "E02"], # bare list → {mode:embryos, embryo_ids:[...]} + "rationale": "Watch temp", + "live": { + "readouts": [{"label": "temp", "value": "25 °C"}], + "phases": [ + {"name": "watch", "state": "pending", "count": 0, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + t = result[0] + assert t["kind"] == "reactive_monitor" + assert t["state"] == "active" + assert t["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + assert t["live"]["phases"][0]["state"] == "todo" + assert t["live"]["readouts"] == [{"label": "temp", "value": "25 °C"}] + + # --------------------------------------------------------------------------- # Registration test # --------------------------------------------------------------------------- From a1c81705822d7bf1fbf7720a66d7de8a9fbfa489 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:06:53 +0530 Subject: [PATCH 156/200] fix(device): bound live-view memory (cap+drain+snap mode) + side-B galvo/piezo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1 — buffer hardening: - Add snap-loop mode (default): _grab_lightsheet_frame_sync uses snapImage()+getImage() per frame; no circular buffer, hard memory ceiling. - Continuous mode (opt-in via _ls_params["mode"]="continuous"): cap buffer with setCircularBufferMemoryFootprint(256) before start; drain with clearCircularBuffer() after each getLastImage() peek. - _stop_lightsheet_sequence_sync: always calls clearCircularBuffer() on every exit path (disconnect, error, pause, view-exit via finally block). FIX 2 — side-B galvo/piezo: - device_factory.py: defensively register scanner_b (Scanner:CD:33) and piezo_b (PiezoStage:Q:35) when present in getLoadedDevices(); skip+log on single-side rigs. - _park_lightsheet_sync: select scanner_b/piezo_b for side B, fall back to A with warning when absent; single-camera rigs unaffected. TDD: 12 tests red → green; 40 target tests pass; 0 new failures in full suite. --- gently/hardware/dispim/device_factory.py | 37 ++++ gently/hardware/dispim/device_layer.py | 137 ++++++++++-- tests/test_dual_camera_factory.py | 56 +++++ tests/test_lightsheet_buffer.py | 252 +++++++++++++++++++++++ tests/test_lightsheet_side_param.py | 131 +++++++++++- tests/test_lightsheet_streamer.py | 6 +- 6 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 tests/test_lightsheet_buffer.py diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index 96b0d276..cfef7d24 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -67,6 +67,9 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", + # Side-B optics: registered defensively (absent on single-side rigs) + "scanner_b_name": "Scanner:CD:33", + "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", @@ -129,6 +132,40 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera_b: %s", e) + # Defensively register the side-B galvo scanner (Scanner:CD:33). + # Absent on single-side rigs — skip + log, do not crash. + try: + scanner_b_name = cfg.get("scanner_b_name", "Scanner:CD:33") + loaded_devices = list(core.getLoadedDevices()) + if scanner_b_name in loaded_devices: + scanner_b = DiSPIMScanner(name=scanner_b_name, core=core) + devices["scanner_b"] = scanner_b + logger.info("Created scanner_b (side B): %s", scanner_b_name) + else: + logger.warning( + "scanner_b (%s) not in loaded devices — single-side rig or device absent; skipping", + scanner_b_name, + ) + except Exception as e: + logger.warning("Could not create scanner_b: %s", e) + + # Defensively register the side-B imaging piezo (PiezoStage:Q:35). + # Absent on single-side rigs — skip + log, do not crash. + try: + piezo_b_name = cfg.get("piezo_b_name", "PiezoStage:Q:35") + loaded_devices = list(core.getLoadedDevices()) + if piezo_b_name in loaded_devices: + piezo_b = DiSPIMPiezo(name=piezo_b_name, core=core) + devices["piezo_b"] = piezo_b + logger.info("Created piezo_b (side B): %s", piezo_b_name) + else: + logger.warning( + "piezo_b (%s) not in loaded devices — single-side rig or device absent; skipping", + piezo_b_name, + ) + except Exception as e: + logger.warning("Could not create piezo_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 69b51581..2c5127a3 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -174,7 +174,15 @@ def __init__( self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows self._ls_target_max_dim: int = 512 self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + self._ls_params: dict = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + # "snap" → per-frame snapImage()+getImage() — hard memory ceiling, safe default. + # "continuous" → startContinuousSequenceAcquisition (opt-in, higher FPS). + "mode": "snap", + } self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values @@ -981,6 +989,11 @@ async def _broadcast_camera(self, payload: dict[str, Any]): def _park_lightsheet_sync(self) -> None: """Park scanner galvo + imaging piezo at the current live params (static sheet). + Side 'A' drives devices["scanner"] / devices["piezo"] (Scanner:AB:33 / + PiezoStage:P:34). Side 'B' drives devices["scanner_b"] / devices["piezo_b"] + (Scanner:CD:33 / PiezoStage:Q:35) when registered. Falls back to side A + with a warning on single-side rigs so they are never broken. + Guards: - set_spim_state("Idle") fires once per stream session, not per frame (4 serial round-trips on first call → 0 on every subsequent frame). @@ -988,8 +1001,32 @@ def _park_lightsheet_sync(self) -> None: last-applied value (no-op on steady-state frames). """ p = self._ls_params - scanner = self.devices.get("scanner") - piezo = self.devices.get("piezo") + side = p.get("side", "A") + + # Resolve scanner for the requested side (with fallback to A). + if side == "B": + if "scanner_b" in self.devices: + scanner = self.devices["scanner_b"] + else: + logger.warning( + "Side B requested but scanner_b not registered; falling back to side A for scanner" + ) + scanner = self.devices.get("scanner") + else: + scanner = self.devices.get("scanner") + + # Resolve piezo for the requested side (with fallback to A). + if side == "B": + if "piezo_b" in self.devices: + piezo = self.devices["piezo_b"] + else: + logger.warning( + "Side B requested but piezo_b not registered; falling back to side A for piezo" + ) + piezo = self.devices.get("piezo") + else: + piezo = self.devices.get("piezo") + # SPIM Idle state machine: drive both devices Idle once per session. if not self._ls_spim_idle: if scanner is not None: @@ -1016,7 +1053,18 @@ def _park_lightsheet_sync(self) -> None: self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure/side change) the continuous sequence on the SPIM camera. + """Configure (or reconfigure on exposure/side change) the SPIM camera. + + Two modes gated by ``_ls_params["mode"]``: + + "snap" (default, safe) — per-frame snapImage()+getImage(). No circular + buffer at all; memory is bounded to one frame. Sets camera device + and exposure only when a reconfiguration is needed; never calls + startContinuousSequenceAcquisition. + + "continuous" (opt-in) — startContinuousSequenceAcquisition with a hard + footprint cap (256 MB) so the buffer can't balloon on a production + box. The grab loop drains with clearCircularBuffer() after each peek. Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] (HamCam2) when registered. If side B is requested but camera_b is absent, @@ -1025,10 +1073,11 @@ def _ensure_lightsheet_sequence_sync(self) -> None: core = self.system.core p = self._ls_params side = p.get("side", "A") + mode = p.get("mode", "snap") # Resolve the camera for the requested side if side == "B" and "camera_b" in self.devices: - cam = self.devices.get("camera_b") + cam = self.devices["camera_b"] else: if side == "B": logger.warning( @@ -1039,33 +1088,76 @@ def _ensure_lightsheet_sequence_sync(self) -> None: if cam is None: raise RuntimeError("No lightsheet camera configured") - need_restart = ( + need_reconfigure = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] or self._ls_applied.get("side") != side ) - if need_restart: - if core.isSequenceRunning(): - core.stopSequenceAcquisition() - if core.getCameraDevice() != cam.name: - core.setCameraDevice(cam.name) - core.setExposure(cam.name, float(p["exposure"])) - core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) - self._ls_seq_started = True - self._ls_applied["exposure"] = p["exposure"] - self._ls_applied["side"] = side + + if mode == "snap": + # Snap mode: configure camera/exposure on change; no continuous sequence. + if need_reconfigure: + # Stop any lingering continuous sequence before snap takes over. + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + try: + core.clearCircularBuffer() + except Exception: + pass + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + self._ls_seq_started = True # "camera configured" flag; no sequence running + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + else: + # Continuous mode: cap buffer footprint then start sequence. + if need_reconfigure: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + # Hard cap: prevent buffer balloon that crashed the production box. + try: + core.setCircularBufferMemoryFootprint(256) + except Exception: + logger.debug( + "setCircularBufferMemoryFootprint not available on this core", + exc_info=True, + ) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): - """Park → ensure sequence running → peek the latest frame (never drain).""" + """Park → configure camera → grab one frame. + + Snap mode (default): snapImage()+getImage() — no circular buffer, + hard memory ceiling, safe for long live-view sessions. + + Continuous mode (opt-in): peeks via getLastImage() then drains the + buffer with clearCircularBuffer() to prevent it sitting full. + """ try: - self._park_lightsheet_sync() # galvo/piezo applied live - self._ensure_lightsheet_sequence_sync() # start / restart on exposure + self._park_lightsheet_sync() + self._ensure_lightsheet_sequence_sync() try: from gently.hardware.dispim.devices.acquisition import _safe_obtain except (ImportError, AttributeError): _safe_obtain = None core = self.system.core - img = core.getLastImage() + mode = self._ls_params.get("mode", "snap") + if mode == "snap": + core.snapImage() + img = core.getImage() + else: + img = core.getLastImage() + try: + core.clearCircularBuffer() + except Exception: + pass if _safe_obtain is not None: try: img = _safe_obtain(img) @@ -1082,6 +1174,11 @@ def _stop_lightsheet_sequence_sync(self) -> None: self.system.core.stopSequenceAcquisition() except Exception: logger.debug("stop lightsheet sequence failed", exc_info=True) + # Drain the buffer on every stop path (snap mode: no-op; continuous: release memory). + try: + self.system.core.clearCircularBuffer() + except Exception: + pass self._ls_seq_started = False self._ls_applied = {} # Reset park guard so the next stream session re-idles the state diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py index 5476184e..7149f82e 100644 --- a/tests/test_dual_camera_factory.py +++ b/tests/test_dual_camera_factory.py @@ -105,3 +105,59 @@ def test_camera_b_absent_on_empty_device_list(): devices = create_devices_from_mmcore(core) assert "camera_b" not in devices assert "camera" in devices + + +# --------------------------------------------------------------------------- +# Side-B optics: scanner_b / piezo_b defensive registration +# --------------------------------------------------------------------------- + + +def test_scanner_b_created_when_present(): + """Scanner:CD:33 in getLoadedDevices → devices['scanner_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "Scanner:CD:33"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices, "scanner_b must be registered when Scanner:CD:33 is loaded" + assert devices["scanner_b"].name == "Scanner:CD:33" + + +def test_scanner_b_absent_when_not_loaded(): + """Scanner:CD:33 NOT loaded → scanner_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + + +def test_piezo_b_created_when_present(): + """PiezoStage:Q:35 in getLoadedDevices → devices['piezo_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" in devices, "piezo_b must be registered when PiezoStage:Q:35 is loaded" + assert devices["piezo_b"].name == "PiezoStage:Q:35" + + +def test_piezo_b_absent_when_not_loaded(): + """PiezoStage:Q:35 NOT loaded → piezo_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" not in devices + + +def test_scanner_b_piezo_b_both_present_on_dual_side_rig(): + """Full dual-side rig: both scanner_b and piezo_b created; side A unchanged.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:CD:33", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices + assert "piezo_b" in devices + # Side-A optics must still be present + assert "scanner" in devices + assert "piezo" in devices + + +def test_scanner_b_piezo_b_both_absent_on_single_side_rig(): + """Single-side rig: neither scanner_b nor piezo_b created; A-side and camera OK.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + assert "piezo_b" not in devices + assert "scanner" in devices + assert "piezo" in devices diff --git a/tests/test_lightsheet_buffer.py b/tests/test_lightsheet_buffer.py new file mode 100644 index 00000000..de9f9235 --- /dev/null +++ b/tests/test_lightsheet_buffer.py @@ -0,0 +1,252 @@ +"""Tests for live-view buffer hardening. + +Verifies: + - snap mode (default) uses snapImage()+getImage() — no circular buffer + - snap mode does NOT call startContinuousSequenceAcquisition + - continuous mode calls setCircularBufferMemoryFootprint(256) before starting + - continuous mode calls clearCircularBuffer() after each getLastImage() + - _stop_lightsheet_sequence_sync calls clearCircularBuffer() in both modes +""" +import sys +from unittest.mock import MagicMock + +import numpy as np +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Full-featured FakeCore tracking every buffer-related call +# --------------------------------------------------------------------------- + + +class BufferFakeCore: + """CMMCore stub that records every buffer/snap/sequence call.""" + + def __init__(self): + self.cam = None + self.running = False + self.started = 0 + self.stopped = 0 + self.exposure = None + self.footprint_set: int | None = None # value passed to setCircularBufferMemoryFootprint + self.clear_called = 0 + self.snap_called = 0 + self.get_image_called = 0 + self.get_last_image_called = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam or "" + + def setExposure(self, n, ms): + self.exposure = ms + + def isSequenceRunning(self): + return self.running + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def setCircularBufferMemoryFootprint(self, mb: int): + self.footprint_set = mb + + def clearCircularBuffer(self): + self.clear_called += 1 + + def snapImage(self): + self.snap_called += 1 + + def getImage(self): + self.get_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + def getLastImage(self): + self.get_last_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + +class FakeAxisOffset: + def __init__(self): + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + +class FakeScanner: + def __init__(self, name): + self.name = name + self.sa_offset_y = FakeAxisOffset() + + def set_spim_state(self, state): + pass + + +class FakePiezo: + def __init__(self, name): + self.name = name + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + def set_spim_state(self, state): + pass + + +def _make_dl_buffer(mode: str = "snap") -> DeviceLayerServer: + """Build a minimal DL for buffer-hardening tests.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + core = BufferFakeCore() + dl.system = type("S", (), {"core": core})() + cam_a = type("C", (), {"name": "HamCam1"})() + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": mode, + } + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# Snap mode — snapImage + getImage, no circular buffer +# --------------------------------------------------------------------------- + + +def test_snap_mode_calls_snapimage_and_getimage(): + """snap mode: _grab_lightsheet_frame_sync calls snapImage() + getImage().""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.snap_called >= 1, "snapImage() must be called in snap mode" + assert core.get_image_called >= 1, "getImage() must be called in snap mode" + + +def test_snap_mode_does_not_call_get_last_image(): + """snap mode: getLastImage() is NOT called (avoids the never-draining buffer).""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.get_last_image_called == 0 + + +def test_snap_mode_does_not_start_continuous_sequence(): + """snap mode: startContinuousSequenceAcquisition is never called.""" + dl = _make_dl_buffer(mode="snap") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 0, ( + "startContinuousSequenceAcquisition must not be called in snap mode" + ) + + +def test_snap_mode_does_not_start_continuous_on_grab(): + """snap mode grab loop never starts continuous acquisition.""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.started == 0 + + +# --------------------------------------------------------------------------- +# Continuous mode — buffer cap + drain +# --------------------------------------------------------------------------- + + +def test_continuous_mode_sets_circular_buffer_footprint(): + """continuous mode: setCircularBufferMemoryFootprint(256) called before start.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + core = dl.system.core + assert core.footprint_set is not None, ( + "setCircularBufferMemoryFootprint must be called in continuous mode" + ) + assert core.footprint_set == 256, ( + f"footprint must be 256 MB, got {core.footprint_set}" + ) + + +def test_continuous_mode_calls_clear_after_grab(): + """continuous mode: clearCircularBuffer() called after each getLastImage().""" + dl = _make_dl_buffer(mode="continuous") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.clear_called >= 1, ( + "clearCircularBuffer() must be called after getLastImage() in continuous mode" + ) + + +def test_continuous_mode_starts_sequence(): + """continuous mode: startContinuousSequenceAcquisition is called.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + + +# --------------------------------------------------------------------------- +# Stop sequence — clearCircularBuffer on every exit path +# --------------------------------------------------------------------------- + + +def test_stop_calls_clear_circular_buffer(): + """_stop_lightsheet_sequence_sync always calls clearCircularBuffer().""" + dl = _make_dl_buffer(mode="continuous") + # Start a sequence first so there's something to stop + dl._ensure_lightsheet_sequence_sync() + clear_before = dl.system.core.clear_called + dl._stop_lightsheet_sequence_sync() + assert dl.system.core.clear_called > clear_before, ( + "clearCircularBuffer must be called in _stop_lightsheet_sequence_sync" + ) + + +def test_stop_resets_seq_started(): + """_stop_lightsheet_sequence_sync resets _ls_seq_started to False.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_seq_started is True + dl._stop_lightsheet_sequence_sync() + assert dl._ls_seq_started is False + + +def test_stop_clears_applied_state(): + """_stop_lightsheet_sequence_sync clears _ls_applied so next start does a full restart.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_applied != {} + dl._stop_lightsheet_sequence_sync() + assert dl._ls_applied == {} diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py index 25009334..e4679c47 100644 --- a/tests/test_lightsheet_side_param.py +++ b/tests/test_lightsheet_side_param.py @@ -68,17 +68,82 @@ def stopSequenceAcquisition(self): def isSequenceRunning(self): return self.running + def setCircularBufferMemoryFootprint(self, mb): + pass # no-op for camera-selection tests -def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: - """Build a minimal DeviceLayerServer with faked core + devices.""" + def clearCircularBuffer(self): + pass # no-op for camera-selection tests + + +class FakeAxisOffset: + """Stub for DiSPIMScanner.sa_offset_y tracking setPosition calls.""" + + def __init__(self): + self.last_pos = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + +class FakeScanner: + """Stub scanner with sa_offset_y and set_spim_state.""" + + def __init__(self, name: str): + self.name = name + self.sa_offset_y = FakeAxisOffset() + self.spim_state = None + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +class FakePiezo: + """Stub piezo tracking setPosition and set_spim_state calls.""" + + def __init__(self, name: str): + self.name = name + self.last_pos = None + self.spim_state = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +def _make_dl( + with_camera_b: bool = True, + with_scanner_b: bool = False, + with_piezo_b: bool = False, +) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices. + + mode is set to "continuous" so existing sequence-start assertions remain + meaningful (snap mode is the production default but tested separately). + """ dl = DeviceLayerServer.__new__(DeviceLayerServer) dl.system = type("S", (), {"core": FakeCore()})() cam_a = type("C", (), {"name": "HamCam1"})() cam_b = type("C", (), {"name": "HamCam2"})() - dl.devices = {"camera": cam_a} + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } if with_camera_b: dl.devices["camera_b"] = cam_b - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + if with_scanner_b: + dl.devices["scanner_b"] = FakeScanner("Scanner:CD:33") + if with_piezo_b: + dl.devices["piezo_b"] = FakePiezo("PiezoStage:Q:35") + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": "continuous", # explicit — snap mode tested in test_lightsheet_buffer.py + } dl._ls_seq_started = False dl._ls_applied = {} dl._ls_interval_sec = 0.0 @@ -221,3 +286,61 @@ async def test_handle_get_cameras_without_camera_b(): resp = await dl.handle_get_cameras(req) body = json.loads(resp.body) assert body["cameras"] == ["A"] + + +# --------------------------------------------------------------------------- +# _park_lightsheet_sync — side selects correct scanner/piezo +# --------------------------------------------------------------------------- + + +def test_side_a_parks_scanner_a(): + """Side A → _park_lightsheet_sync drives scanner A's galvo.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_b_parks_scanner_b(): + """Side B with scanner_b present → _park_lightsheet_sync drives scanner B.""" + dl = _make_dl(with_scanner_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["scanner_b"].sa_offset_y.last_pos is not None + # Side A scanner must be untouched + assert dl.devices["scanner"].sa_offset_y.last_pos is None + + +def test_side_b_scanner_b_absent_falls_back_to_scanner_a(): + """Side B without scanner_b → graceful fallback to scanner A, no crash.""" + dl = _make_dl(with_scanner_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + # Fallback: scanner A gets the park + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_a_parks_piezo_a(): + """Side A → _park_lightsheet_sync drives piezo A.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None + + +def test_side_b_parks_piezo_b(): + """Side B with piezo_b present → _park_lightsheet_sync drives piezo B.""" + dl = _make_dl(with_scanner_b=True, with_piezo_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo_b"].last_pos is not None + # Side A piezo must be untouched + assert dl.devices["piezo"].last_pos is None + + +def test_side_b_piezo_b_absent_falls_back_to_piezo_a(): + """Side B without piezo_b → graceful fallback to piezo A, no crash.""" + dl = _make_dl(with_piezo_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index c36377a1..9091ea13 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -64,7 +64,8 @@ async def test_grab_parks_and_peeks(monkeypatch): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + # Explicit continuous mode: this test verifies the sequence-start + getLastImage path. + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) @@ -79,7 +80,8 @@ async def test_exposure_change_restarts_sequence(): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + # Explicit continuous mode: this test verifies restart on exposure change. + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 await asyncio.to_thread(dl._grab_lightsheet_frame_sync) From 656e97496f3851f11030f823cc76d07a8b2e42c4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:19:36 +0530 Subject: [PATCH 157/200] feat(manual): timelapse config as an accordion with active/inactive section states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the flat timelapse panel into 3 independently collapsible accordion sections — Schedule (open by default), Targets, Volume geometry. Active/inactive state: - Each section header starts muted (hollow 5-px dot, dimmed label). - Any user input/change event in a section flips it active: dot fills accent cyan, label turns accent, a one-line summary appears inline. - Summaries: "60 s · 5 frames" / "all · Idle (plain imaging)" / "80 sl · 10 ms · ALL OFF". - The outer Timelapse panel header shows an accent dot once any section is set; the Start Timelapse button turns accent-bordered when ready. - Re-entering the manual view (initTlForm) resets all touched state and clears visual active state. Payload unchanged: all field IDs preserved, POST body identical. node --check: exit 0. --- gently/ui/web/static/css/main.css | 132 ++++++++++++++++ gently/ui/web/static/js/devices.js | 121 +++++++++++++-- gently/ui/web/templates/index.html | 235 ++++++++++++++++------------- 3 files changed, 366 insertions(+), 122 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 9215466a..7e04b232 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10937,3 +10937,135 @@ body.modal-open { } .ls-tl-status[hidden] { display: none; } + +/* ── Timelapse accordion (active/inactive section states — B3 UX) ─────────── */ + +/* Wrapper for each accordion section */ +.ls-acc-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--map-rule-soft); + padding-bottom: 0.12rem; + margin-bottom: 0.08rem; +} +.ls-acc-section:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +/* Section header button */ +.ls-acc-head { + display: flex; + align-items: center; + gap: 0.32rem; + background: transparent; + border: none; + padding: 0.26rem 0; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +/* Status dot — hollow/muted when inactive */ +.ls-acc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid var(--map-ink-mute); + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +/* Section label */ +.ls-acc-title { + font-size: 0.575rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: color 0.15s; +} + +/* One-line summary — shown only when section is touched */ +.ls-acc-summary { + flex: 1; + font-size: 0.57rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.02em; + color: var(--map-ink-mute); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.ls-acc-summary[hidden] { display: none; } + +/* Caret arrow */ +.ls-acc-arrow { + font-size: 0.44rem; + color: var(--map-ink-mute); + flex-shrink: 0; + margin-left: auto; + transition: transform 0.15s, color 0.15s; +} +.ls-acc-head[aria-expanded="true"] .ls-acc-arrow { + transform: rotate(90deg); +} + +/* ── Active / touched state ────────────────────────────────────────────────── */ +.ls-acc-head.is-active .ls-acc-dot { + background: var(--map-accent); + border-color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-title { + color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-summary { + color: var(--map-accent); + opacity: 0.85; +} +.ls-acc-head.is-active .ls-acc-arrow { + color: var(--map-accent); +} + +/* Section body */ +.ls-acc-body { + display: flex; + flex-direction: column; + gap: 0.26rem; + padding: 0.18rem 0 0.32rem 0.5rem; +} +.ls-acc-body[hidden] { display: none; } + +/* Outer timelapse panel header dot — signals any section is set */ +.ls-tl-outer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +.ls-tl-outer-dot.is-active { + background: var(--map-accent); + border-color: var(--map-accent); +} + +/* Start button reads "ready" (accent) once any section is configured */ +.ls-tl-start-btn { + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ls-tl-start-btn.is-ready { + border-color: var(--map-accent); + color: var(--map-accent); + background: rgba(34, 211, 238, 0.08); +} + +/* Submit row — give the button its full row width */ +.ls-tl-submit-row { + margin-top: 0.3rem; +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 2319d9b2..67f8ca5d 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -113,6 +113,8 @@ const DevicesManager = (function () { let _tlEmbryos, _tlMode; let _tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr, _tlLaser; let _tlStart, _tlStatus, _tlStatusText; + // Accordion active-state per section: { sched, targets, geom } + let _tlTouched = { sched: false, targets: false, geom: false }; // Room-light toggle (header). Drives the SwitchBot Bot that switches the // diSPIM room light. State is the bot's cached on/off; hidden until the @@ -1486,12 +1488,71 @@ const DevicesManager = (function () { // Timelapse config form (Manual view) // ===================================================================== - /** Wire the timelapse panel's collapsible toggle and stop-condition selector. - * Safe to call multiple times (idempotent via re-assignment of event handlers). */ + // ── Accordion section summary builders ─────────────────────────────────── + + function _tlSchedSummary() { + const interval = (_tlInterval && _tlInterval.value) ? _tlInterval.value : '120'; + const stop = (_tlStop && _tlStop.value) ? _tlStop.value : 'manual'; + const condVal = (_tlCondVal && _tlCondVal.value) ? _tlCondVal.value : '10'; + if (stop === 'timepoints') return `${interval} s · ${condVal} frames`; + if (stop === 'duration') return `${interval} s · ${condVal} h`; + return `${interval} s · manual`; + } + + function _tlTargetsSummary() { + const embryos = (_tlEmbryos && _tlEmbryos.value.trim()) + ? _tlEmbryos.value.trim() + : 'all'; + const modeEl = _tlMode; + const modeText = (modeEl && modeEl.value) + ? modeEl.options[modeEl.selectedIndex].text + : 'none'; + return `${embryos} · ${modeText}`; + } + + function _tlGeomSummary() { + const slices = (_tlSlices && _tlSlices.value) ? _tlSlices.value : '50'; + const exposure = (_tlExposure && _tlExposure.value) ? _tlExposure.value : '10'; + const laser = (_tlLaser && _tlLaser.value) ? _tlLaser.value : 'ALL OFF'; + return `${slices} sl · ${exposure} ms · ${laser}`; + } + + /** Update a section's header active state and summary text, then sync the + * outer panel dot and the start button. sec = 'sched'|'targets'|'geom'. */ + function _tlUpdateSection(sec) { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const summary = document.getElementById(`devices-tlacc-${sec}-sum`); + const touched = _tlTouched[sec]; + + if (head) head.classList.toggle('is-active', touched); + if (summary) { + summary.hidden = !touched; + if (touched) { + if (sec === 'sched') summary.textContent = _tlSchedSummary(); + else if (sec === 'targets') summary.textContent = _tlTargetsSummary(); + else if (sec === 'geom') summary.textContent = _tlGeomSummary(); + } + } + + // Outer panel dot + start button "ready" state + const anyActive = Object.values(_tlTouched).some(Boolean); + const outerDot = document.getElementById('devices-tl-outer-dot'); + if (outerDot) outerDot.classList.toggle('is-active', anyActive); + if (_tlStart) _tlStart.classList.toggle('is-ready', anyActive); + } + + /** Wire the timelapse panel: outer collapsible toggle, accordion section + * toggles, touch listeners, and the submit button. + * Safe to call multiple times (re-assigns handlers idempotently). */ function initTlForm() { cacheDom(); - // Collapsible head + // Reset touched state on each init (re-entering the manual view = fresh) + _tlTouched = { sched: false, targets: false, geom: false }; + // Clear any leftover active-state visuals from a previous visit + ['sched', 'targets', 'geom'].forEach(sec => _tlUpdateSection(sec)); + + // ── Outer collapsible toggle ────────────────────────────────────────── if (_tlToggle && _tlBody) { _tlToggle.onclick = () => { const open = _tlBody.hidden; @@ -1502,22 +1563,52 @@ const DevicesManager = (function () { }; } - // Conditional stop-value row: show for timepoints / duration, hide for manual - if (_tlStop && _tlCondRow) { - _tlStop.onchange = () => { + // ── Accordion section toggles ───────────────────────────────────────── + ['sched', 'targets', 'geom'].forEach(sec => { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const body = document.getElementById(`devices-tlacc-${sec}-body`); + if (!head || !body) return; + head.onclick = () => { + const open = body.hidden; + body.hidden = !open; + head.setAttribute('aria-expanded', String(open)); + const arrow = head.querySelector('.ls-acc-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + }); + + // ── Touch listeners ─────────────────────────────────────────────────── + const markTouched = sec => { + _tlTouched[sec] = true; + _tlUpdateSection(sec); + }; + + // Schedule — interval and stop condition drive summary; cond-row visibility unchanged + [_tlInterval, _tlCondVal].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('sched')); + }); + if (_tlStop) { + _tlStop.addEventListener('change', () => { const v = _tlStop.value; const show = v === 'timepoints' || v === 'duration'; - _tlCondRow.hidden = !show; - if (_tlCondLabel) { - _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; - } - }; + if (_tlCondRow) _tlCondRow.hidden = !show; + if (_tlCondLabel) _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; + markTouched('sched'); + }); } - // Submit button - if (_tlStart) { - _tlStart.onclick = startTimelapse; - } + // Targets + if (_tlEmbryos) _tlEmbryos.addEventListener('input', () => markTouched('targets')); + if (_tlMode) _tlMode.addEventListener('change', () => markTouched('targets')); + + // Volume geometry + [_tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('geom')); + }); + if (_tlLaser) _tlLaser.addEventListener('change', () => markTouched('geom')); + + // ── Submit ──────────────────────────────────────────────────────────── + if (_tlStart) _tlStart.onclick = startTimelapse; } /** Populate timelapse volume-geometry defaults from GET /api/devices/scan_geometry, diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 55090248..b069c156 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -849,127 +849,148 @@

    Properties

    - +
    - -
    - + +
    + +
    + + +
    +
    +
    +

    + + +
    From 3887f979b77334d90dea16c1dc597c5f3685711b Mon Sep 17 00:00:00 2001 From: Kesavan Date: Mon, 29 Jun 2026 13:12:51 -0400 Subject: [PATCH 170/200] refactor(manual-view): remove dead CAM LED button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CAM LED toggle posted use_led to the bottom camera, but camera.py's trigger() intentionally ignores that flag ("the bottom camera NEVER drives the LED — imaging is done under room light only"). The control was a no-op on the rig and crowded the Illumination row. Remove the button and its now-unused JS wiring (lookup, listener, the toggleCamLedMode handler, and the _lsCamLed/_lsCamLedOn state). LED and Room Light now share the top illumination row with more room. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/js/devices.js | 20 +------------------- gently/ui/web/templates/index.html | 2 -- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a5dc270d..5467f39d 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -104,9 +104,8 @@ const DevicesManager = (function () { // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsRoomLightBtn; let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) - let _lsCamLedOn = false; let _lsLaserToggle; let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; @@ -216,7 +215,6 @@ const DevicesManager = (function () { _lsPiezoNum = document.getElementById('devices-ls-piezo'); _lsExposureNum = document.getElementById('devices-ls-exposure'); _lsLedToggle = document.getElementById('devices-ls-led-toggle'); - _lsCamLed = document.getElementById('devices-ls-cam-led'); _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); _lsLaserToggle = document.getElementById('devices-ls-laser-toggle'); _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume'); @@ -2044,21 +2042,6 @@ const DevicesManager = (function () { } } - async function toggleCamLedMode() { - _lsCamLedOn = !_lsCamLedOn; - if (_lsCamLed) { - _lsCamLed.classList.toggle('ls-illum-btn--active', _lsCamLedOn); - _lsCamLed.setAttribute('aria-pressed', _lsCamLedOn ? 'true' : 'false'); - } - try { - await fetch('/api/devices/camera/led_mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ use_led: _lsCamLedOn }), - }); - } catch (err) { console.debug('cam LED mode failed:', err); } - } - async function toggleManualRoomLight() { const nextState = _roomLightState === 'on' ? 'off' : 'on'; if (_lsRoomLightBtn) { @@ -2165,7 +2148,6 @@ const DevicesManager = (function () { // Illumination if (_lsLedToggle) _lsLedToggle.addEventListener('click', toggleLedPreset); - if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode); if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); if (_lsLaserToggle) _lsLaserToggle.addEventListener('click', toggleLaser); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 5b669a68..da7f500c 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -801,8 +801,6 @@

    Properties

    - From eaa1d507b6326993a0561d7f6a9914e841e2e7cb Mon Sep 17 00:00:00 2001 From: Kesavan Date: Mon, 29 Jun 2026 13:19:10 -0400 Subject: [PATCH 171/200] fix(web): stop acquire routes returning raw numpy arrays (500 on Snap/Burst) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap Volume / Burst in the Manual view hit /api/devices/acquire/volume|burst, which returned client.acquire_*()'s dict verbatim. That dict carries the pixel data under "volume" as a numpy array (internal callers like the timelapse orchestrator need it), and FastAPI's jsonable_encoder can't serialize an ndarray — it falls back to dict(arr) and raises "dictionary update sequence element #0 has length 512; 2 is required", returning a 500. Add _json_safe() in the data routes and apply it to both acquire responses: raw arrays become a small {shape, dtype} hint, numpy scalars coerce to native types, and it recurses so the burst "frames" list is covered. The web UI only reads volume_path/metadata (never the pixels), so nothing downstream changes. The client return shape is untouched, so non-web callers keep their arrays. Verified: volume + burst sample results now json.dumps cleanly with volume_path preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/data.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 075117be..5281e290 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -16,6 +16,30 @@ _HARDWARE_CONFIG_PATH = Path(__file__).resolve().parents[4] / "config" / "hardware.yaml" +def _json_safe(obj): + """Make an acquisition result JSON-encodable for FastAPI. + + ``client.acquire_volume``/``acquire_burst`` return the pixel data under + ``volume``/``image`` as numpy arrays (internal callers like the timelapse + orchestrator need them). FastAPI's ``jsonable_encoder`` can't serialize a + raw ndarray — it tries ``dict(arr)`` and blows up — and the web UI only + needs paths + metadata anyway. Replace arrays with a small shape/dtype + hint and coerce numpy scalars to native types; recurse so the burst + ``frames`` list is covered too. + """ + import numpy as np + + if isinstance(obj, np.ndarray): + return {"shape": list(obj.shape), "dtype": str(obj.dtype)} + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(v) for v in obj] + return obj + + def create_router(server) -> APIRouter: router = APIRouter() @@ -633,13 +657,14 @@ async def acquire_burst(payload: dict = Body(...)): # noqa: B008 kw["piezo_center"] = float(payload["piezo_center"]) if payload.get("galvo_center") is not None: kw["galvo_center"] = float(payload["galvo_center"]) - return await client.acquire_burst( + result = await client.acquire_burst( frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), num_slices=int(payload.get("num_slices", 1)), exposure_ms=float(payload.get("exposure_ms", 5.0)), **kw, ) + return _json_safe(result) except Exception as exc: logger.exception("Burst acquisition failed") raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc @@ -665,11 +690,12 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 kw["piezo_center"] = float(payload["piezo_center"]) if payload.get("galvo_center") is not None: kw["galvo_center"] = float(payload["galvo_center"]) - return await client.acquire_volume( + result = await client.acquire_volume( num_slices=int(payload.get("num_slices", 50)), exposure_ms=float(payload.get("exposure_ms", 10.0)), **kw, ) + return _json_safe(result) except Exception as exc: logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc From 60b948e188841dda3c08a40e910060068ec0d57b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 17:54:15 +0530 Subject: [PATCH 172/200] feat(devices): Map-view embryo detect + single-source-of-truth list + per-embryo Center + bottom-cam enlarge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/devices/detect_embryos (bottom-cam SAM) — interim auto-register - Device-tab Embryos card renders the canonical experiment.embryos list (EMBRYOS_UPDATE); per-row Center calls /api/devices/stage/move; per-row remove - Bottom-camera enlarge toggle (height-capped, fits the map) - Right-side rail stacks XY readout + Embryos card (no overlap) Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/data.py | 84 ++++++++++++ gently/ui/web/static/css/main.css | 202 +++++++++++++++++++++++++++++ gently/ui/web/static/js/devices.js | 197 +++++++++++++++++++++++++++- gently/ui/web/templates/index.html | 30 ++++- 4 files changed, 509 insertions(+), 4 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 075117be..e21a3422 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -608,6 +608,90 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 logger.exception("Stage move command failed") raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + @router.post("/api/devices/detect_embryos", dependencies=[Depends(require_control)]) + async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 + """Run bottom-camera embryo detection and register the hits into the + canonical experiment embryo list — the single source of truth. + + Registration goes through ExperimentState.add_embryo(), which fires + EMBRYOS_UPDATE (and EMBRYO_DETECTED), so the device-tab Map view and + every other embryo consumer refresh automatically — there is no second + list. Raw SAM hits are added as role 'unassigned'; the operator prunes + false positives from the same list (DELETE /api/embryos/{id}). Each + Detect run appends fresh hits with new ids. + + Body (all optional): {exposure_ms, min_confidence, brightness_percentile, + min_area, max_area, use_claude_review}. Claude review defaults OFF so the + manual flow is fast and needs no API key; brightness+SAM detections + already carry stage coordinates. + + Returns: {success, detected, registered: [embryo_id, ...]}. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + if not getattr(client, "has_sam", False): + raise HTTPException( + status_code=503, detail="SAM detection not available on device layer" + ) + agent = _require_agent_with_experiment() + + kw: dict = {"use_claude_review": bool(payload.get("use_claude_review", False))} + for key, cast in ( + ("exposure_ms", float), + ("min_confidence", float), + ("brightness_percentile", float), + ("min_area", int), + ("max_area", int), + ): + if payload.get(key) is not None: + kw[key] = cast(payload[key]) + try: + result = await client.detect_embryos(**kw) + except Exception as exc: + logger.exception("Embryo detection failed") + raise HTTPException(status_code=502, detail=f"detection failed: {exc}") from exc + + if not result.get("success"): + detail = result.get("error", "detection failed") + raise HTTPException(status_code=502, detail=str(detail)) + + import uuid + + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + def _next_embryo_id() -> str: + n = 1 + while f"embryo_{n}" in agent.experiment.embryos: + n += 1 + return f"embryo_{n}" + + detections = result.get("embryos", []) or [] + registered: list[str] = [] + for emb in detections: + sx, sy = _num(emb.get("stage_x_um")), _num(emb.get("stage_y_um")) + if sx is None or sy is None: + continue + emb_id = _next_embryo_id() + agent.experiment.add_embryo( + embryo_id=emb_id, + position={"x": sx, "y": sy}, + confidence=_num(emb.get("confidence")) or 0.0, + uid=str(uuid.uuid4()), + role="unassigned", + ) + registered.append(emb_id) + + return { + "success": True, + "detected": len(detections), + "registered": registered, + } + # ------------------------------------------------------------------ # Acquisition # ------------------------------------------------------------------ diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index d7b51a37..f4fcdccc 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10219,6 +10219,208 @@ body.modal-open { color: var(--map-paper); border-color: var(--map-accent); } + +/* ---- Top-right rail: XY readout stacked above the detect panel ---- */ +.devices-map-rail { + position: absolute; + top: 0.85rem; + right: 0.85rem; + width: 230px; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 4; +} +/* Readout drops its own absolute positioning inside the rail and fills it. */ +.devices-map-rail .devices-map-readout { + position: static; + top: auto; + right: auto; + width: 100%; + min-width: 0; +} + +/* ---- Detected-embryo list panel (Map view, standalone detect→centre) ---- */ +.devices-detect-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.5rem 0.6rem 0.55rem; + background: var(--map-overlay-bg-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 3px; + box-shadow: 0 12px 32px rgba(0,0,0,0.32); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + font-family: 'Inter Tight', system-ui, sans-serif; +} +.devices-detect-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.devices-detect-title { + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--map-ink); +} +.devices-detect-btn { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.18rem 0.6rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-detect-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} +.devices-detect-btn:disabled { opacity: 0.5; cursor: progress; } +.devices-detect-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 220px; + overflow-y: auto; +} +.devices-detect-empty { + font-size: 0.66rem; + color: var(--map-ink-mute); + padding: 0.35rem 0.1rem; + line-height: 1.35; +} +.devices-detect-row { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.28rem 0.3rem; + border-radius: 3px; + transition: background 0.12s; +} +.devices-detect-row:hover { background: rgba(255,255,255,0.05); } +.devices-detect-dot { + flex: 0 0 auto; + width: 18px; height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + font-weight: 700; + color: var(--map-paper); + background: var(--map-warm); +} +.devices-detect-coord { + flex: 1 1 auto; + font-size: 0.64rem; + font-variant-numeric: tabular-nums; + color: var(--map-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.devices-detect-center { + flex: 0 0 auto; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.16rem 0.45rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-detect-center:hover:not(:disabled) { + background: var(--map-accent); + border-color: var(--map-accent); + color: var(--map-paper); +} +.devices-detect-center:disabled { opacity: 0.5; cursor: progress; } +/* Per-row remove (prune false positives from the canonical list) */ +.devices-detect-remove { + flex: 0 0 auto; + background: transparent; + border: none; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.7rem; + line-height: 1; + padding: 0.18rem 0.25rem; + border-radius: 3px; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} +.devices-detect-remove:hover:not(:disabled) { + color: var(--map-warm); + background: rgba(255,255,255,0.06); +} +.devices-detect-remove:disabled { opacity: 0.5; cursor: progress; } +.devices-detect-foot { + font-size: 0.62rem; + color: var(--map-ink-mute); + padding-top: 0.1rem; +} + +.devices-camera-head-btns { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.devices-camera-expand { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.72rem; + line-height: 1; + padding: 0.16rem 0.4rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-camera-expand:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} +.devices-camera-expand.active { + background: var(--map-accent); + color: var(--map-paper); + border-color: var(--map-accent); +} +/* Enlarged bottom-camera view — centred over the map, toggled by the + expand button. Reuses the same streaming ; sized by height so the + whole panel (header + stage + foot) stays within the map area. */ +.devices-camera-panel.expanded { + bottom: auto; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: auto; + max-width: calc(100% - 2rem); + z-index: 9; +} +.devices-camera-panel.expanded .devices-camera-stage { + height: min(64vh, 640px); + width: auto; + max-width: 100%; +} .devices-camera-stage { position: relative; width: 100%; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 8c1c8733..905a7dc3 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -52,8 +52,16 @@ const DevicesManager = (function () { // Backspace removes it (with a confirm), Escape clears the selection. let _selectedEmbryoId = null; + // Bottom-camera detection (Map view). Detection registers hits into the + // canonical _embryos list (server-side, via the experiment) — there is no + // separate detected list. The Embryos card renders _embryos and centres the + // stage on each via /api/devices/stage/move. _detecting only gates the + // Detect button while a run is in flight. + let _detecting = false; + let _detectBtn, _detectList; + // Bottom-camera panel DOM + state - let _camPanel, _camToggle, _camImg, _camPlaceholder, _camLed, _camMeta; + let _camPanel, _camToggle, _camExpand, _camImg, _camPlaceholder, _camLed, _camMeta; let _camStage, _camCrosshair, _camCrosshairGroup; let _camStreaming = false; let _camLastFrameTs = 0; @@ -176,6 +184,8 @@ const DevicesManager = (function () { _mapOrigin = document.getElementById('devices-map-origin'); _mapAxes = document.getElementById('devices-map-axes'); _mapEmbryos = document.getElementById('devices-map-embryos'); + _detectBtn = document.getElementById('devices-detect-btn'); + _detectList = document.getElementById('devices-detect-list'); _mapMarker = document.getElementById('devices-map-marker'); _mapMarkerPulse = document.getElementById('devices-map-marker-pulse'); _mapMarkerRing = document.getElementById('devices-map-marker-ring'); @@ -187,6 +197,7 @@ const DevicesManager = (function () { _camPanel = document.getElementById('devices-camera-panel'); _camToggle = document.getElementById('devices-camera-toggle'); + _camExpand = document.getElementById('devices-camera-expand'); _camImg = document.getElementById('devices-camera-img'); _camPlaceholder = document.getElementById('devices-camera-placeholder'); _camStage = _camPanel ? _camPanel.querySelector('.devices-camera-stage') : null; @@ -416,6 +427,7 @@ const DevicesManager = (function () { } else { renderEmbryos(); } + renderEmbryoListPanel(); } // ===================================================================== @@ -646,7 +658,7 @@ const DevicesManager = (function () { } function handleEmbryoDetected(payload) { - if (_upsertEmbryo(payload)) renderEmbryos(); + if (_upsertEmbryo(payload)) { renderEmbryos(); renderEmbryoListPanel(); } } function handleStatusChanged(payload) { @@ -659,6 +671,7 @@ const DevicesManager = (function () { if (idx >= 0) { _embryos[idx] = Object.assign({}, _embryos[idx], { role: newRole }); renderEmbryos(); + renderEmbryoListPanel(); } else { // Embryo we haven't seen yet — refetch to be safe. loadEmbryos(); @@ -1364,9 +1377,21 @@ const DevicesManager = (function () { } } + function toggleCameraExpand() { + if (!_camPanel) return; + const expanded = _camPanel.classList.toggle('expanded'); + if (_camExpand) { + _camExpand.classList.toggle('active', expanded); + _camExpand.setAttribute('aria-pressed', expanded ? 'true' : 'false'); + _camExpand.title = expanded ? 'Shrink view' : 'Enlarge view'; + _camExpand.textContent = expanded ? '⤡' : '⤢'; + } + } + function setupCameraWiring() { if (!_camToggle) return; _camToggle.addEventListener('click', toggleCameraStream); + if (_camExpand) _camExpand.addEventListener('click', toggleCameraExpand); applyCameraState(false); if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('BOTTOM_CAMERA_FRAME', handleCameraFrame); @@ -2420,11 +2445,179 @@ const DevicesManager = (function () { scheduleStaleCheck(); } + // ===================================================================== + // Embryo detection + per-embryo stage centring (Map view) + // ===================================================================== + // Single source of truth: the canonical _embryos list (fed by + // EMBRYOS_UPDATE / /api/embryos/current). Detect registers hits server-side + // into the experiment, which broadcasts EMBRYOS_UPDATE back here — the same + // list that imported / timelapse / manually-marked embryos live in. The + // panel below renders that one list with a Centre + remove action per row. + + // Render the Embryos card list from _embryos. Called from renderEmbryos() + // so the card and the map dots always reflect the same canonical state. + function renderEmbryoListPanel() { + if (!_detectList) return; + _detectList.innerHTML = ''; + if (!_embryos || !_embryos.length) { + const empty = document.createElement('div'); + empty.className = 'devices-detect-empty'; + empty.textContent = _detecting + ? 'Detecting…' + : 'No embryos yet — press Detect'; + _detectList.appendChild(empty); + return; + } + _embryos.forEach((emb, i) => { + const xy = embryoResolvedXY(emb); + const row = document.createElement('div'); + row.className = 'devices-detect-row'; + row.setAttribute('data-embryo-id', emb.id || ''); + + const dot = document.createElement('span'); + dot.className = 'devices-detect-dot'; + dot.style.background = _ROLE_COLOR[emb.role] || _ROLE_COLOR.unassigned; + dot.textContent = embryoLabelText(emb.id, i); + row.appendChild(dot); + + const coord = document.createElement('span'); + coord.className = 'devices-detect-coord'; + coord.textContent = xy + ? `(${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` + : 'no position'; + row.appendChild(coord); + + const center = document.createElement('button'); + center.className = 'devices-detect-center'; + center.type = 'button'; + center.textContent = 'Center'; + center.disabled = !xy; + center.addEventListener('click', () => centerOnEmbryo(emb, center)); + row.appendChild(center); + + const remove = document.createElement('button'); + remove.className = 'devices-detect-remove'; + remove.type = 'button'; + remove.title = 'Remove this embryo'; + remove.setAttribute('aria-label', 'Remove embryo'); + remove.textContent = '✕'; + remove.addEventListener('click', () => removeEmbryo(emb, remove)); + row.appendChild(remove); + + _detectList.appendChild(row); + }); + } + + async function runDetection() { + if (_detecting || !_detectBtn) return; + _detecting = true; + _detectBtn.disabled = true; + _detectBtn.textContent = 'Detecting…'; + renderEmbryoListPanel(); + try { + const res = await fetch('/api/devices/detect_embryos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const detail = await res.text(); + console.error('Embryo detection failed:', detail); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Detection failed (${res.status})`); + } + return; + } + const data = await res.json(); + // The canonical list refreshes via EMBRYOS_UPDATE; just report. + if (typeof showGentlyToast === 'function') { + const n = Array.isArray(data.registered) ? data.registered.length : 0; + showGentlyToast(`Detected ${n} embryo${n === 1 ? '' : 's'}`); + } + } catch (err) { + console.error('Detection request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Detection error: ${err.message}`); + } + } finally { + _detecting = false; + _detectBtn.disabled = false; + _detectBtn.textContent = 'Detect'; + renderEmbryoListPanel(); + } + } + + async function centerOnEmbryo(emb, btn) { + const xy = emb ? embryoResolvedXY(emb) : null; + if (!xy) return; + const num = embryoNumberFor(emb); + if (btn) { btn.disabled = true; btn.textContent = '…'; } + try { + const res = await fetch('/api/devices/stage/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: xy.x, y: xy.y }), + }); + if (!res.ok) { + console.error('Centre failed:', await res.text()); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Centre failed (${res.status})`); + } + return; + } + if (typeof showGentlyToast === 'function') { + showGentlyToast( + `Centred on embryo ${num} — (${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` + ); + } + } catch (err) { + console.error('Centre request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Centre error: ${err.message}`); + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Center'; } + } + } + + async function removeEmbryo(emb, btn) { + if (!emb || !emb.id) return; + const num = embryoNumberFor(emb); + if (!window.confirm(`Remove embryo ${num}?`)) return; + if (btn) btn.disabled = true; + try { + const res = await fetch(`/api/embryos/${encodeURIComponent(emb.id)}`, { + method: 'DELETE', + }); + if (!res.ok) { + console.error('Remove failed:', await res.text()); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Remove failed (${res.status})`); + } + if (btn) btn.disabled = false; + return; + } + // EMBRYOS_UPDATE refreshes the list + map dots. + } catch (err) { + console.error('Remove request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Remove error: ${err.message}`); + } + if (btn) btn.disabled = false; + } + } + + function setupDetectWiring() { + if (_detectBtn) _detectBtn.addEventListener('click', runDetection); + renderEmbryoListPanel(); + } + function init() { cacheDom(); setupViewSwitcher(); setupCameraWiring(); setupManualWiring(); + setupDetectWiring(); setupRoomLight(); setupTemperature(); loadCoverslip(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 109d3be8..62c79e24 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -533,7 +533,10 @@

    Device - + +
    @@ -552,6 +555,24 @@

    Device

    + +
    +
    + Embryos + +
    +
    +
    + No embryos yet — press Detect +
    +
    + +
    +
    +
    @@ -575,7 +596,12 @@

    Device Bottom camera - +
    + + +

    bottom camera live frame From 6ffe1f96a852322e346727790c0f0079ebd9d867 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 17:54:15 +0530 Subject: [PATCH 173/200] =?UTF-8?q?docs(spec):=20bottom-cam=E2=86=92SPIM?= =?UTF-8?q?=20Operator=20surface=20design=20(sub-project=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-30-bottom-cam-operator-surface-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md diff --git a/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md new file mode 100644 index 00000000..4228e84b --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md @@ -0,0 +1,123 @@ +# Bottom-cam → SPIM Operator Surface ("Operate" view) — Design + +Date: 2026-06-30 +Status: Approved (proceed to implementation) +Branch: feature/temperature-operations-all + +## Purpose + +A single, professional, guided operator surface for the manual bottom-camera → +SPIM acquisition workflow, replacing the scattered current UX (bottom cam on the +Map view, SPIM controls on the Manual view, embryo marking on the Embryos page). +Mirrors how the operator physically works the rig: + +1. Focus the bottom objective on the embryos. +2. Mark **all** embryos in one pass (a single FOV holds them). +3. Per embryo: center → lower the SPIM head → focus the SPIM (LED on) → acquire. + +This is sub-project **A** (the spine). Two data flywheels hang off it and are +specified separately: **B** marking→localization labels (retire SAM), **C** +manual-focus→autofocus-validation. A exposes the hook points (confirm, focus +score) they will tap, but does not implement them. + +## Settled decisions + +- **Home:** a new `Operate` view in the device tab, alongside Map/Details/3D/Manual. + One purpose per view: Map stays a passive spatial monitor; Manual stays raw + knobs. The Detect/Center/enlarge controls already added to Map migrate to Operate; + Map keeps only read-only embryo dots. +- **Focus control:** software **nudge** buttons (± fixed steps), hard-fenced to + the axis limits (F-drive floor 30 µm). **No autofocus** (objective-crash risk). + Live focus-score readout to assist. +- **Marking:** batch — mark all embryos on one frozen full-res frame. **Positions + only, no roles.** Roles are a separate, later, optional concern. +- **SPIM focus step:** inline in Operate (lightsheet live + galvo/piezo/LED nudges), + not a handoff to Manual. +- **Single source of truth:** the canonical `experiment.embryos` list (already wired + via EMBRYOS_UPDATE / /api/embryos/current). Detect feeds it through a + human-confirm step, not a side list. +- **UI quality:** treat as a design pass (frontend-design), not a port of the + amateur marking canvas. + +## Architecture + +New device-tab view `operate` (devices.js view list becomes +`['operate','map','details','optical3d','manual']`), three vertical zones: + +1. **Survey** — enlarged bottom-cam live; bottom-Z focus nudge (fenced) + live + focus score; Detect (SAM → candidates) or click-to-mark on a frozen frame; + Confirm. +2. **Embryos** — the one canonical list, each row with a state chip and select. +3. **Acquire** (selected embryo) — Center → Lower SPIM head (F-drive, fenced) → + inline lightsheet live + galvo/piezo/LED nudges + focus score → Acquire volume. + +Per-embryo state machine (client-side, keyed by embryo id; persistence deferred): + +``` +marked ──Center──▶ centered ──Lower SPIM + focus──▶ focused ──Acquire──▶ imaged +``` + +### Component reuse + +| Need | Reuse | New | +|---|---|---| +| Bottom-cam live + enlarge | camera panel (built) | move into Operate | +| Mark-all on frozen frame | MarkingManager interaction logic | re-homed canvas, redesigned, positions-only | +| Embryo list + Center | SSOT list + stage/move (built) | per-embryo state chips | +| SPIM focus | Manual galvo/piezo/LED/lightsheet-live endpoints | inline placement | +| Acquire | /api/devices/acquire/volume | — | +| Bottom-Z + F-drive nudge | DiSPIMZstage / DiSPIMFDrive device classes | device-factory wiring, polling, fenced endpoints | +| Focus score | analysis/core.calculate_focus_score | inject into camera stream payloads | +| Register marks (agent-free) | experiment.add_embryo | register-on-confirm endpoint | + +## New backend endpoints (web routes proxy → device layer) + +- `GET /api/devices/stage/bottom_z` · `POST /api/devices/stage/bottom_z/nudge {delta}` + — read + fenced nudge of the bottom-camera focus Z (DiSPIMZstage). +- `GET /api/devices/spim/fdrive` · `POST /api/devices/spim/fdrive/nudge {delta}` + — read + fenced nudge of the SPIM-head F-drive (floor 30 µm; report distance-to-floor). +- `POST /api/devices/detect_embryos` (revised) — return SAM candidates + `{embryos:[{pixel_x,pixel_y,stage_x_um,stage_y_um,confidence}], stage_position}`; + no auto-register. +- `POST /api/devices/embryos/confirm {markers:[{pixel_x,pixel_y}], stage_position, + pixel_size_um, objective_mag}` — pixel→stage, register each into experiment.embryos + (role 'unassigned'), fire EMBRYOS_UPDATE. Agent-free. +- Focus score injected into existing bottom-cam + lightsheet SSE payloads + (`focus_score` field), computed server-side on the full frame. + +Device-layer additions: instantiate DiSPIMZstage in the device factory when +present; add bottom-Z and F-drive to the slow position poller; fenced nudge +handlers (clamp/reject out-of-range). + +## Safety / error handling + +- All Z moves fenced server-side; device classes hard-enforce limits; out-of-range + → 4xx, surfaced in UI. Nudges are bounded single steps — no autonomous/repeated + moves. +- Device layer down → 503; controls disabled with clear state. +- Frozen-frame capture failure → error toast, stay in Survey. Confirm with zero + markers → disabled. +- F-drive: never below floor; show distance-to-floor. + +## Testing + +- Backend (TDD): pixel→stage in register-on-confirm (reuse coordinate tests); + fenced Z endpoints reject out-of-range; focus-score payload shape. +- Frontend: launch with the gently_perception shim; Chrome MCP drives + detect→mark→confirm→EMBRYOS_UPDATE, per-embryo state transitions, out-of-range + nudge blocked; screenshots + UI audit against the professional bar. +- Rig-only: real Z moves, real SPIM focus, SAM on a live frame. + +## Out of scope (separate sub-projects) + +- B: persist (frame + pixel markers + roles) as localization labels; benchmark + classical/trained detectors vs SAM. +- C: poll missing Z axes for passive focus-trace logging + offline validator. + (A wires the Z axes for read/move, which C extends to logging.) + +## MVP boundary + +Ship the Operate view end-to-end with maximal reuse: Survey (live + fenced bottom-Z ++ Detect/mark-all + Confirm), the SSOT list with state chips + Center, Acquire zone +(F-drive nudge + inline SPIM focus controls + Acquire). Per-embryo state client-side. +Defer label/focus-trace persistence (B/C). From 223812c7c9c7699c9cbbfa2fb5878116f90a712c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 18:49:47 +0530 Subject: [PATCH 174/200] =?UTF-8?q?feat(operate):=20backend=20=E2=80=94=20?= =?UTF-8?q?fenced=20Z=20read/nudge,=20agent-free=20marking=20confirm,=20de?= =?UTF-8?q?tect=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device layer + client: - Register bottom-cam focus Z (DiSPIMZstage, z_stage) in device_factory; poll it + the SPIM-head F-drive in the slow position stream. - Fenced read+nudge handlers for bottom_z and fdrive (reject out-of-range; the device classes hard-enforce limits; F-drive floor is sacrosanct). - Inject a focus score (analysis.core.calculate_focus_score on the full frame) into the bottom-cam + lightsheet stream payloads. - Client: get_bottom_z/nudge_bottom_z/get_fdrive/nudge_fdrive. Web routes (data.py): - detect_embryos now RETURNS candidates (no auto-register) for the marking canvas. - POST /api/devices/embryos/confirm — agent-free register of operator-confirmed markers into experiment.embryos (single source of truth), fires EMBRYOS_UPDATE. - Proxies: stage/bottom_z[/nudge], spim/fdrive[/nudge]. RIG-NOTE: z_stage MMCore axis assumed ZStage:Z:32 + limits (50,250)µm — rig owner to confirm. Nudges use direct device.set() (mirrors camera-exposure handler). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/hardware/dispim/client.py | 22 ++++ gently/hardware/dispim/device_factory.py | 21 +++ gently/hardware/dispim/device_layer.py | 160 ++++++++++++++++++++++- gently/ui/web/routes/data.py | 158 +++++++++++++++++----- 4 files changed, 326 insertions(+), 35 deletions(-) diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index b4b38557..b4461436 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -1050,6 +1050,28 @@ async def get_bottom_camera_exposure(self) -> dict: """Get current bottom camera exposure time.""" return await self._api_get("/api/camera/exposure") + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z and SPIM-head F-drive. + # Read + relative nudge only (no autofocus). Out-of-range nudges come + # back as a non-success dict (the device layer returns 400). + # ------------------------------------------------------------------ + + async def get_bottom_z(self) -> dict: + """Current bottom-camera focus Z position + limits.""" + return await self._api_get("/api/stage/bottom_z") + + async def nudge_bottom_z(self, delta: float) -> dict: + """Fenced relative move of the bottom-camera focus Z by ``delta`` µm.""" + return await self._api_post("/api/stage/bottom_z/nudge", {"delta": float(delta)}) + + async def get_fdrive(self) -> dict: + """Current SPIM-head F-drive position + limits + distance to floor.""" + return await self._api_get("/api/spim/fdrive") + + async def nudge_fdrive(self, delta: float) -> dict: + """Fenced relative move of the SPIM-head F-drive by ``delta`` µm.""" + return await self._api_post("/api/spim/fdrive/nudge", {"delta": float(delta)}) + async def capture_bottom_image( self, use_led: bool = False, exposure_ms: float | None = None ) -> dict: diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index b0bfb81c..503cb491 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -71,6 +71,9 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non "scanner_b_name": "Scanner:CD:33", "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", + # Bottom-camera focus Z (sample Z). ASI Tiger "ZStage:Z:32" — distinct + # from the SPIM-head F-drive (ZStage:V:37). Registered defensively below. + "z_stage_name": "ZStage:Z:32", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", } @@ -195,6 +198,24 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create F-drive (SPIM head): %s", e) + # Bottom-camera focus Z (sample Z). Registered defensively: rigs that don't + # expose the axis under z_stage_name simply skip it (focus stays manual). + try: + from .devices import DiSPIMZstage + + z_stage_name = cfg.get("z_stage_name", "ZStage:Z:32") + loaded_devices = list(core.getLoadedDevices()) + if z_stage_name in loaded_devices: + devices["z_stage"] = DiSPIMZstage(name=z_stage_name, core=core) + logger.info("Created bottom-camera focus Z (z_stage): %s", z_stage_name) + else: + logger.warning( + "z_stage (%s) not in loaded devices — bottom-cam focus Z absent; skipping", + z_stage_name, + ) + except Exception as e: + logger.warning("Could not create z_stage (bottom-cam focus Z): %s", e) + try: if cfg.get("led_name"): from .devices import DiSPIMLED diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0042f5af..8383c486 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -621,6 +621,31 @@ def _read_slow_positions(self) -> dict[str, Any]: except Exception as exc: logger.debug("Galvo position read failed: %s", exc) + # SPIM-head F-drive — not on its own knob loop, so 1 Hz is plenty. + # Streamed so the Operate view can show head height + distance-to-floor. + fdrive = self.devices.get("fdrive") + if fdrive is not None: + try: + data = fdrive.read() + out[fdrive.name] = { + "Position": float(data[fdrive.name]["value"]), + "kind": "fdrive", + } + except Exception as exc: + logger.debug("F-drive position read failed: %s", exc) + + # Bottom-camera focus Z (sample Z) — present only on rigs that expose it. + z_stage = self.devices.get("z_stage") + if z_stage is not None: + try: + data = z_stage.read() + out[z_stage.name] = { + "Position": float(data[z_stage.name]["value"]), + "kind": "bottom_z", + } + except Exception as exc: + logger.debug("Bottom-Z position read failed: %s", exc) + return out def _read_full_state(self) -> dict[str, dict[str, str]]: @@ -894,6 +919,18 @@ def _encode_frame_for_stream( target_max_dim = max_dim if max_dim is not None else self._cam_target_max_dim jpeg_quality = quality if quality is not None else self._cam_jpeg_quality + # Focus score on the FULL frame (before downsampling) so the Operate + # view's focus readout reflects true sharpness, not the JPEG thumbnail. + # Best-effort: any failure just omits the field. + focus_score: float | None = None + try: + from gently.analysis.core import calculate_focus_score + + focus_score = float(calculate_focus_score(img, algorithm="volath")) + except Exception as exc: + logger.debug("Focus score computation failed: %s", exc) + focus_score = None + h, w = img.shape[:2] # Stride slicing — no interpolation, just take every Nth pixel. factor = max(1, max(h, w) // target_max_dim) @@ -922,13 +959,16 @@ def _encode_frame_for_stream( if not ok: return None b64 = base64.b64encode(jpeg.tobytes()).decode("ascii") - return { + payload: dict[str, Any] = { "t": time.time(), "shape": [int(small.shape[0]), int(small.shape[1])], "downsample": factor, "mime": "image/jpeg", "jpeg_b64": b64, } + if focus_score is not None: + payload["focus_score"] = focus_score + return payload async def _bottom_camera_streamer(self): """Continuous grab/encode/broadcast loop. Runs while a subscriber lives. @@ -2239,6 +2279,119 @@ async def handle_get_camera_exposure(self, request): status=500, ) + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z (z_stage) + SPIM-head F-drive. + # Read + relative nudge only; the device classes enforce hard limits, and + # the handlers reject out-of-range targets with 400. No autofocus. + # ------------------------------------------------------------------ + + def _nudge_axis_blocking(self, device, target: float) -> float: + """Blocking: move a fenced ophyd positioner to ``target`` and return the + new position. ``device.set()`` enforces the hardware limits.""" + status = device.set(target) + try: + status.wait() + except Exception: + logger.exception("Axis move to %.2f did not complete cleanly", target) + return float(device.read()[device.name]["value"]) + + def _axis_status_response(self, device_key: str, label: str): + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + pos = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + return web.json_response( + { + "success": True, + "position": pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": pos - float(lo), + } + ) + + async def _handle_axis_nudge(self, request, device_key: str, label: str): + try: + data = await request.json() + delta = float(data.get("delta", 0.0)) + except Exception: + return web.json_response( + {"success": False, "error": "numeric 'delta' required"}, status=400 + ) + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + cur = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + target = round(cur + delta, 2) + if not (float(lo) <= target <= float(hi)): + return web.json_response( + { + "success": False, + "error": f"target {target} outside limits [{lo}, {hi}]", + "position": cur, + "min": float(lo), + "max": float(hi), + }, + status=400, + ) + try: + async with self.pause_state_updates(): + new_pos = await asyncio.to_thread(self._nudge_axis_blocking, device, target) + except ValueError as exc: # device-level fence (defensive) + return web.json_response({"success": False, "error": str(exc)}, status=400) + except Exception as exc: + import traceback + + return web.json_response( + { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + }, + status=500, + ) + return web.json_response( + { + "success": True, + "position": new_pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": new_pos - float(lo), + } + ) + + async def handle_get_bottom_z(self, request): + """GET /api/stage/bottom_z — bottom-camera focus Z position + limits.""" + return self._axis_status_response("z_stage", "Bottom-Z (z_stage)") + + async def handle_nudge_bottom_z(self, request): + """POST /api/stage/bottom_z/nudge — fenced relative move of the bottom-camera focus Z.""" + return await self._handle_axis_nudge(request, "z_stage", "Bottom-Z (z_stage)") + + async def handle_get_fdrive(self, request): + """GET /api/spim/fdrive — SPIM-head F-drive position + limits + distance to floor.""" + return self._axis_status_response("fdrive", "F-drive") + + async def handle_nudge_fdrive(self, request): + """POST /api/spim/fdrive/nudge — fenced relative move of the SPIM-head F-drive.""" + return await self._handle_axis_nudge(request, "fdrive", "F-drive") + async def handle_get_plan_log(self, request): """GET /api/plan_log - Get recent plan execution log with timing""" try: @@ -3205,6 +3358,11 @@ async def on_start(self): self._app.router.add_post("/api/camera/led_mode", self.handle_set_camera_led_mode) self._app.router.add_post("/api/camera/exposure", self.handle_set_camera_exposure) self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure) + # Fenced focus axes (read + relative nudge; no autofocus) + self._app.router.add_get("/api/stage/bottom_z", self.handle_get_bottom_z) + self._app.router.add_post("/api/stage/bottom_z/nudge", self.handle_nudge_bottom_z) + self._app.router.add_get("/api/spim/fdrive", self.handle_get_fdrive) + self._app.router.add_post("/api/spim/fdrive/nudge", self.handle_nudge_fdrive) self._app.router.add_post("/api/light_source/power", self.handle_set_light_source_power) self._app.router.add_get("/api/light_source/power", self.handle_get_light_source_power) self._app.router.add_post("/api/laser/config", self.handle_set_laser_config) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index e21a3422..d9867c2a 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -608,24 +608,26 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 logger.exception("Stage move command failed") raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + @router.post("/api/devices/detect_embryos", dependencies=[Depends(require_control)]) async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 - """Run bottom-camera embryo detection and register the hits into the - canonical experiment embryo list — the single source of truth. - - Registration goes through ExperimentState.add_embryo(), which fires - EMBRYOS_UPDATE (and EMBRYO_DETECTED), so the device-tab Map view and - every other embryo consumer refresh automatically — there is no second - list. Raw SAM hits are added as role 'unassigned'; the operator prunes - false positives from the same list (DELETE /api/embryos/{id}). Each - Detect run appends fresh hits with new ids. + """Run bottom-camera embryo detection and RETURN the candidates for the + Operate-view marking canvas. Does NOT register — the operator confirms + (add/remove/relocate) on a frozen frame, then POSTs /api/devices/embryos/ + confirm. This keeps the human in the loop and the canonical embryo list + clean (a marking step, not a blind auto-register). Body (all optional): {exposure_ms, min_confidence, brightness_percentile, - min_area, max_area, use_claude_review}. Claude review defaults OFF so the - manual flow is fast and needs no API key; brightness+SAM detections - already carry stage coordinates. + min_area, max_area, use_claude_review}. Claude review defaults OFF. - Returns: {success, detected, registered: [embryo_id, ...]}. + Returns: {success, count, stage_position: [x, y] | null, + embryos: [{embryo_id, pixel_x, pixel_y, stage_x_um, stage_y_um, + confidence, area_pixels, bbox_pixel}]}. """ client = _resolve_client() if client is None: @@ -634,7 +636,6 @@ async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 raise HTTPException( status_code=503, detail="SAM detection not available on device layer" ) - agent = _require_agent_with_experiment() kw: dict = {"use_claude_review": bool(payload.get("use_claude_review", False))} for key, cast in ( @@ -653,44 +654,133 @@ async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 raise HTTPException(status_code=502, detail=f"detection failed: {exc}") from exc if not result.get("success"): - detail = result.get("error", "detection failed") - raise HTTPException(status_code=502, detail=str(detail)) + raise HTTPException( + status_code=502, detail=str(result.get("error", "detection failed")) + ) - import uuid + embryos = [] + for emb in result.get("embryos", []) or []: + bbox = emb.get("bbox_pixel") + embryos.append( + { + "embryo_id": emb.get("embryo_id"), + "pixel_x": _num(emb.get("pixel_x")), + "pixel_y": _num(emb.get("pixel_y")), + "stage_x_um": _num(emb.get("stage_x_um")), + "stage_y_um": _num(emb.get("stage_y_um")), + "confidence": _num(emb.get("confidence")), + "area_pixels": emb.get("area_pixels"), + "bbox_pixel": list(bbox) if bbox is not None else None, + } + ) + pos = result.get("stage_position") + return { + "success": True, + "count": len(embryos), + "stage_position": list(pos) if pos is not None else None, + "embryos": embryos, + } - def _num(v): - try: - return float(v) - except (TypeError, ValueError): - return None + @router.post("/api/devices/embryos/confirm", dependencies=[Depends(require_control)]) + async def confirm_embryos(payload: dict = Body(...)): # noqa: B008 + """Register operator-confirmed markers into the canonical embryo list. - def _next_embryo_id() -> str: + Agent-free commit step for the Operate-view marking canvas. The client + computes each marker's stage XY from the frozen frame (SAM candidates + carry server-computed stage coords; clicked markers are converted with + the frame's downsample-aware transform), so the server just registers + via ExperimentState.add_embryo(role='unassigned'), firing EMBRYOS_UPDATE. + + Body: {markers: [{stage_x_um, stage_y_um, confidence?}]}. + Returns {success, registered: [embryo_id, ...]}. + """ + agent = _require_agent_with_experiment() + markers = payload.get("markers") or [] + + def _next_embryo_id(taken: set) -> str: n = 1 - while f"embryo_{n}" in agent.experiment.embryos: + while f"embryo_{n}" in agent.experiment.embryos or f"embryo_{n}" in taken: n += 1 return f"embryo_{n}" - detections = result.get("embryos", []) or [] + import uuid + registered: list[str] = [] - for emb in detections: - sx, sy = _num(emb.get("stage_x_um")), _num(emb.get("stage_y_um")) + taken: set = set() + for m in markers: + sx, sy = _num(m.get("stage_x_um")), _num(m.get("stage_y_um")) if sx is None or sy is None: continue - emb_id = _next_embryo_id() + emb_id = _next_embryo_id(taken) + taken.add(emb_id) agent.experiment.add_embryo( embryo_id=emb_id, position={"x": sx, "y": sy}, - confidence=_num(emb.get("confidence")) or 0.0, + confidence=_num(m.get("confidence")) or 0.0, uid=str(uuid.uuid4()), role="unassigned", ) registered.append(emb_id) - return { - "success": True, - "detected": len(detections), - "registered": registered, - } + return {"success": True, "registered": registered} + + # ------------------------------------------------------------------ + # Focus Z axes (Operate view) — fenced read + nudge + # ------------------------------------------------------------------ + + @router.get("/api/devices/stage/bottom_z") + async def get_bottom_z(): + """Bottom-camera focus Z position + limits (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_bottom_z() + except Exception as exc: + logger.debug("bottom_z read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"bottom_z read failed: {exc}") from exc + + @router.post("/api/devices/stage/bottom_z/nudge", dependencies=[Depends(require_control)]) + async def nudge_bottom_z(payload: dict = Body(...)): # noqa: B008 + """Nudge the bottom-camera focus Z by {delta} µm (fenced to its limits).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_bottom_z(delta) + except Exception as exc: + logger.exception("bottom_z nudge failed") + raise HTTPException(status_code=502, detail=f"bottom_z nudge failed: {exc}") from exc + + @router.get("/api/devices/spim/fdrive") + async def get_fdrive(): + """SPIM-head F-drive position + limits + distance-to-floor (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_fdrive() + except Exception as exc: + logger.debug("fdrive read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"fdrive read failed: {exc}") from exc + + @router.post("/api/devices/spim/fdrive/nudge", dependencies=[Depends(require_control)]) + async def nudge_fdrive(payload: dict = Body(...)): # noqa: B008 + """Nudge the SPIM-head F-drive by {delta} µm (fenced; never below floor).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_fdrive(delta) + except Exception as exc: + logger.exception("fdrive nudge failed") + raise HTTPException(status_code=502, detail=f"fdrive nudge failed: {exc}") from exc # ------------------------------------------------------------------ # Acquisition From 7a95f049c46e5c2c192bb47136edddc9a18efefe Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 18:49:47 +0530 Subject: [PATCH 175/200] =?UTF-8?q?feat(operate):=20Operate=20view=20?= =?UTF-8?q?=E2=80=94=20guided=20bottom-cam=20=E2=86=92=20SPIM=20operator?= =?UTF-8?q?=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New device-tab 'Operate' view (1st in the switcher, hotkey 'o'): a single guided flow mirroring the physical workflow, replacing the scattered detect/mark/SPIM UX. - Zone 1 Survey & mark: enlarged bottom-cam live + fenced bottom-Z focus nudge + live focus score; Detect (SAM candidates) or click-to-mark on a FROZEN frame; positions only (no forced roles); Confirm → register into the canonical list. - Zone 2 Embryos: the single source of truth (EMBRYOS_UPDATE) with per-embryo state chips (marked→centered→focused→imaged) + select. - Zone 3 Acquire: Center (stage move) → lower SPIM head (F-drive, fenced) → inline SPIM focus (lightsheet live + galvo/piezo/LED nudge + focus score) → Acquire volume. Professional, theme-aware styling (operate.css). DevicesManager activates/ deactivates OperateManager on view switch (streams only run while visible). Verified in-browser end-to-end (detect→mark→confirm→list→per-embryo cycle). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/operate.css | 194 ++++++++++++ gently/ui/web/static/js/devices.js | 11 +- gently/ui/web/static/js/operate.js | 451 +++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 97 ++++++ 4 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 gently/ui/web/static/css/operate.css create mode 100644 gently/ui/web/static/js/operate.js diff --git a/gently/ui/web/static/css/operate.css b/gently/ui/web/static/css/operate.css new file mode 100644 index 00000000..29ba5f90 --- /dev/null +++ b/gently/ui/web/static/css/operate.css @@ -0,0 +1,194 @@ +/* Operate view — guided bottom-cam → SPIM operator surface. + Theme-aware via the global tokens (--bg-card, --border, --text, --accent …). */ + +.operate { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 0.25rem 0.1rem 1.5rem; + font-family: 'Inter Tight', system-ui, sans-serif; + color: var(--text); +} + +.op-zone { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.85rem 1rem 1rem; +} +.op-zone-head { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; + margin-bottom: 0.85rem; + color: var(--text); +} +.op-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; height: 22px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 0.74rem; + font-weight: 700; +} +.op-count, .op-sel { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-muted); +} + +/* survey + acquire share a camera|rail two-column body. + Fixed body height keeps all three zones on screen; the camera fills it and + the image letterboxes (object-fit: contain) so any frame aspect fits. */ +.op-survey-body, .op-acquire-body { + display: flex; + gap: 1rem; + align-items: stretch; + height: 340px; +} +.op-cam { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 100%; + background: var(--img-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.op-cam-img { + width: 100%; height: 100%; + object-fit: contain; + display: block; + opacity: 0; + transition: opacity 0.2s; +} +.op-cam-img.has-frame { opacity: 1; } +.op-mark-canvas { + position: absolute; inset: 0; + width: 100%; height: 100%; + pointer-events: none; +} +.op-cam.op-marking { cursor: crosshair; box-shadow: inset 0 0 0 2px var(--accent-green); } +.op-cam.op-marking .op-mark-canvas { pointer-events: auto; } +.op-cam-ph { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); + font-size: 0.8rem; + letter-spacing: 0.04em; +} + +/* control rail */ +.op-rail { + flex: 0 0 248px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.op-field { display: flex; flex-direction: column; gap: 0.4rem; } +.op-label { + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); +} +.op-readout { + font-size: 0.82rem; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.op-readout b { font-weight: 600; } +.op-sep { color: var(--text-muted); margin: 0 0.4rem; } + +/* buttons */ +.op-btn { + appearance: none; + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + font: 600 0.8rem/1 'Inter Tight', system-ui, sans-serif; + padding: 0.5rem 0.75rem; + border-radius: 7px; + cursor: pointer; + transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s; +} +.op-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.op-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.op-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.op-btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.op-btn-ghost { background: transparent; } +.op-btn-on, .op-btn-toggle[aria-pressed="true"] { background: var(--accent-green); border-color: var(--accent-green); color: #06281a; } + +.op-mark-actions, .op-spim-controls { display: flex; gap: 0.5rem; } +.op-mark-actions .op-btn, .op-spim-controls .op-btn { flex: 1; } +.op-hint { font-size: 0.72rem; color: var(--text-muted); line-height: 1.4; } + +/* nudge clusters */ +.op-nudge { display: flex; gap: 0.35rem; } +.op-nbtn { + flex: 1; + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + font: 600 0.74rem/1 'Inter Tight', system-ui, sans-serif; + font-variant-numeric: tabular-nums; + padding: 0.4rem 0.3rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.op-nbtn:hover { border-color: var(--accent); color: var(--accent); } +.op-micro { + display: flex; align-items: center; gap: 0.4rem; + font-size: 0.74rem; color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.op-micro b { color: var(--text); min-width: 2.2em; text-align: center; } +.op-micro .op-nbtn { flex: 0 0 auto; width: 26px; } + +/* embryo list */ +.op-embryo-list { display: flex; flex-direction: column; gap: 0.3rem; max-height: 220px; overflow-y: auto; } +.op-empty { color: var(--text-muted); font-size: 0.82rem; padding: 0.4rem 0.1rem; } +.op-erow { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.45rem 0.55rem; + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.op-erow:hover { background: var(--bg-hover); } +.op-erow-sel { background: var(--bg-hover); border-color: var(--accent); } +.op-edot { + flex: 0 0 auto; + width: 22px; height: 22px; + border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-size: 0.7rem; font-weight: 700; +} +.op-ecoord { flex: 1 1 auto; font-size: 0.8rem; font-variant-numeric: tabular-nums; color: var(--text); } +.op-echip { + flex: 0 0 auto; + font-size: 0.64rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + padding: 0.16rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-muted); +} +.op-echip-centered { color: var(--accent); border-color: var(--accent); } +.op-echip-focused { color: var(--accent-orange); border-color: var(--accent-orange); } +.op-echip-imaged { color: var(--accent-green); border-color: var(--accent-green); } + +@media (max-width: 920px) { + .op-survey-body, .op-acquire-body { flex-direction: column; } + .op-rail { flex-basis: auto; } +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 905a7dc3..e7cb9b1b 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -14,7 +14,7 @@ */ const DevicesManager = (function () { const STALE_AFTER_MS = 4000; - const VIEWS = ['map', 'details', 'optical3d', 'manual']; + const VIEWS = ['operate', 'map', 'details', 'optical3d', 'manual']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -2403,6 +2403,12 @@ const DevicesManager = (function () { // populateLaserPresets() runs after setLaserOff() so the select is always // seeded with the entry-safety state first. if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); initTlForm(); populateTlDefaults(); } + // The Operate view owns its own module; activate/deactivate it so its + // camera + SPIM streams only run while it's the visible view. + if (typeof OperateManager !== 'undefined') { + if (viewName === 'operate') OperateManager.activate(); + else OperateManager.deactivate(); + } } function setupViewSwitcher() { @@ -2412,7 +2418,8 @@ const DevicesManager = (function () { document.addEventListener('keydown', (e) => { if (typeof state !== 'undefined' && typeof TABS !== 'undefined' && state.tab !== TABS.DEVICES) return; if (e.target.matches('input, textarea, select, [contenteditable]')) return; - if (e.key === 'm') { e.preventDefault(); switchView('map'); } + if (e.key === 'o') { e.preventDefault(); switchView('operate'); } + else if (e.key === 'm') { e.preventDefault(); switchView('map'); } else if (e.key === 'd') { e.preventDefault(); switchView('details'); } else if (e.key === 'v') { e.preventDefault(); switchView('optical3d'); } }); diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js new file mode 100644 index 00000000..9a7071c1 --- /dev/null +++ b/gently/ui/web/static/js/operate.js @@ -0,0 +1,451 @@ +/** + * Operate view — guided bottom-cam → SPIM operator surface. + * + * Three zones, mirroring the physical workflow: + * 1. Survey & mark — live bottom cam, fenced bottom-Z focus nudge, Detect / + * click-to-mark all embryos on a frozen frame, Confirm. + * 2. Embryos — the single canonical experiment.embryos list (EMBRYOS_UPDATE), + * each row a per-embryo state chip + select. + * 3. Acquire — for the selected embryo: Center → lower SPIM head (F-drive, + * fenced) → focus SPIM (live + galvo/piezo/LED nudge) → Acquire. + * + * Single source of truth: marking confirms into experiment.embryos via the + * agent-free /api/devices/embryos/confirm endpoint; the list refreshes from the + * canonical EMBRYOS_UPDATE event. No autonomous Z moves — every focus move is a + * bounded, server-fenced nudge. + */ +const OperateManager = (function () { + // Camera transform constants (match gently/core/coordinates.py defaults). + const BASE_UM_PER_PX = 6.5 / 10.0; // pixel_size_um / objective_mag + const MARK_HIT_PX = 14; // click-to-remove radius (canvas px) + + let _wired = false; + let _active = false; + + // DOM + let _camImg, _camPh, _camStage, _markCanvas, _camToggle; + let _bzPos, _bzScore, _bzNudge; + let _detectBtn, _confirmBtn, _clearBtn, _markCount, _markHint; + let _embryoList, _embryoCount; + let _selLabel, _spimImg, _spimPh, _spimToggle, _ledBtn; + let _fdPos, _fdFloor, _fdNudge, _centerBtn, _acquireBtn; + let _gvVal, _pzVal, _spimScore; + + // State + let _camOn = false, _spimOn = false; + let _lastFrame = null; // last live bottom-cam payload {jpeg_b64, shape, downsample, focus_score} + let _marking = false; + let _frozenSrc = null; // frozen image data URL + let _frozenFrame = null; // {w, h, downsample} of the frozen frame (frame-space) + let _captureStage = [0, 0]; // stage XY at freeze time (µm) + let _markers = []; // [{fx, fy, stageX, stageY, source}] fx/fy in frame-space px + let _embryos = []; // canonical list (EMBRYOS_UPDATE shape) + let _selected = null; // selected embryo id + const _states = {}; // embryo_id -> 'marked'|'centered'|'focused'|'imaged' + let _lastXY = null; // {X, Y} from DEVICE_STATE_UPDATE + let _galvo = 0.0, _piezo = 50.0, _ledOn = false; + + const _ROLE_COLOR = { test: '#ff66cc', calibration: '#00cccc', unassigned: '#8a8f98' }; + const _STATE_LABEL = { marked: 'marked', centered: 'centered', focused: 'focused', imaged: 'imaged' }; + + function $(id) { return document.getElementById(id); } + function toast(msg) { if (typeof showGentlyToast === 'function') showGentlyToast(msg); } + + async function postJSON(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + const err = new Error(`${res.status} ${detail}`); + err.status = res.status; + throw err; + } + return res.json().catch(() => ({})); + } + async function getJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + } + + function cacheDom() { + _camImg = $('op-cam-img'); _camPh = $('op-cam-ph'); _camStage = $('op-cam-stage'); + _markCanvas = $('op-mark-canvas'); _camToggle = $('op-cam-toggle'); + _bzPos = $('op-bz-pos'); _bzScore = $('op-bz-score'); _bzNudge = $('op-bz-nudge'); + _detectBtn = $('op-detect'); _confirmBtn = $('op-confirm'); _clearBtn = $('op-clear'); + _markCount = $('op-mark-count'); _markHint = $('op-mark-hint'); + _embryoList = $('op-embryo-list'); _embryoCount = $('op-embryo-count'); + _selLabel = $('op-sel-label'); _spimImg = $('op-spim-img'); _spimPh = $('op-spim-ph'); + _spimToggle = $('op-spim-toggle'); _ledBtn = $('op-led'); + _fdPos = $('op-fd-pos'); _fdFloor = $('op-fd-floor'); _fdNudge = $('op-fd-nudge'); + _centerBtn = $('op-center'); _acquireBtn = $('op-acquire'); + _gvVal = $('op-gv'); _pzVal = $('op-pz'); _spimScore = $('op-spim-score'); + } + + // ---- Bottom-camera survey ------------------------------------------- + async function toggleCamera() { + _camToggle.disabled = true; + try { + const ep = _camOn ? '/api/devices/bottom_camera/stream/stop' + : '/api/devices/bottom_camera/stream/start'; + const data = await postJSON(ep, {}); + applyCameraState(!!data.streaming); + } catch (err) { + toast(`Camera toggle failed (${err.status || err.message})`); + } finally { + _camToggle.disabled = false; + } + } + function applyCameraState(on) { + _camOn = on; + _camToggle.textContent = on ? 'Stop camera' : 'Start camera'; + _camToggle.classList.toggle('op-btn-on', on); + if (!on && !_marking) { _camImg.classList.remove('has-frame'); _camPh.style.display = ''; _camPh.textContent = 'Camera off'; } + else if (on && !_marking) { _camPh.textContent = 'waiting…'; } + } + function onBottomFrame(p) { + if (!p || !p.jpeg_b64) return; + _lastFrame = p; + if (p.focus_score != null) _bzScore.textContent = Number(p.focus_score).toFixed(3); + if (_marking) return; // keep the frozen frame visible while marking + _camImg.src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!_camImg.classList.contains('has-frame')) { _camImg.classList.add('has-frame'); _camPh.style.display = 'none'; } + } + + // ---- Marking -------------------------------------------------------- + function umPerPxDisplay() { + const ds = (_frozenFrame && _frozenFrame.downsample) || 1; + return BASE_UM_PER_PX * ds; + } + // frame-space (downsampled px) -> stage µm, using the frozen capture pose. + function frameToStage(fx, fy) { + const u = umPerPxDisplay(); + const cx = _frozenFrame.w / 2, cy = _frozenFrame.h / 2; + return [_captureStage[0] + (fx - cx) * u, _captureStage[1] - (fy - cy) * u]; + } + function enterMarking(candidates) { + if (!_lastFrame) { toast('Start the camera first'); return false; } + _marking = true; + _frozenFrame = { w: _lastFrame.shape[1], h: _lastFrame.shape[0], downsample: _lastFrame.downsample || 1 }; + _captureStage = _lastFrame.stage_position || (_lastXY ? [_lastXY.X, _lastXY.Y] : [0, 0]); + _frozenSrc = `data:${_lastFrame.mime || 'image/jpeg'};base64,${_lastFrame.jpeg_b64}`; + _camImg.src = _frozenSrc; _camImg.classList.add('has-frame'); _camPh.style.display = 'none'; + _markers = []; + (candidates || []).forEach(c => { + const ds = _frozenFrame.downsample; + const fx = (c.pixel_x != null) ? c.pixel_x / ds : _frozenFrame.w / 2; + const fy = (c.pixel_y != null) ? c.pixel_y / ds : _frozenFrame.h / 2; + const stage = (c.stage_x_um != null && c.stage_y_um != null) + ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: stage[0], stageY: stage[1], source: 'sam' }); + }); + _camStage.classList.add('op-marking'); + _markHint.textContent = 'Click to add an embryo · click a marker to remove · Confirm when done.'; + drawMarkers(); updateMarkActions(); + return true; + } + function exitMarking() { + _marking = false; _markers = []; _frozenSrc = null; _frozenFrame = null; + _camStage.classList.remove('op-marking'); + clearCanvas(); + updateMarkActions(); + _markHint.textContent = 'Start the camera, then Detect — or click the image to mark embryos.'; + if (_camOn && _lastFrame) onBottomFrame(_lastFrame); + } + // rendered image rect (object-fit: contain) within the stage box. + function renderedRect() { + const sb = _camStage.getBoundingClientRect(); + const fw = _frozenFrame ? _frozenFrame.w : (_lastFrame ? _lastFrame.shape[1] : sb.width); + const fh = _frozenFrame ? _frozenFrame.h : (_lastFrame ? _lastFrame.shape[0] : sb.height); + const ar = fw / fh, sar = sb.width / sb.height; + let w, h; + if (ar > sar) { w = sb.width; h = sb.width / ar; } else { h = sb.height; w = sb.height * ar; } + return { x: (sb.width - w) / 2, y: (sb.height - h) / 2, w, h, fw, fh, sb }; + } + function clearCanvas() { + if (!_markCanvas) return; + const ctx = _markCanvas.getContext('2d'); + ctx && ctx.clearRect(0, 0, _markCanvas.width, _markCanvas.height); + } + function drawMarkers() { + if (!_markCanvas || !_marking) return; + const r = renderedRect(); + _markCanvas.width = Math.round(r.sb.width); _markCanvas.height = Math.round(r.sb.height); + const ctx = _markCanvas.getContext('2d'); + ctx.clearRect(0, 0, _markCanvas.width, _markCanvas.height); + _markers.forEach((m, i) => { + const cx = r.x + (m.fx / r.fw) * r.w; + const cy = r.y + (m.fy / r.fh) * r.h; + ctx.beginPath(); ctx.arc(cx, cy, 11, 0, 7); + ctx.lineWidth = 2; ctx.strokeStyle = '#34d399'; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx - 6, cy); ctx.lineTo(cx + 6, cy); + ctx.moveTo(cx, cy - 6); ctx.lineTo(cx, cy + 6); ctx.stroke(); + ctx.fillStyle = '#34d399'; ctx.font = '600 11px Inter Tight, system-ui, sans-serif'; + ctx.fillText(String(i + 1), cx + 13, cy - 8); + }); + } + function onCanvasClick(e) { + if (!_marking) return; + const r = renderedRect(); + const rect = _markCanvas.getBoundingClientRect(); + const cxv = e.clientX - rect.left, cyv = e.clientY - rect.top; + // hit-test existing markers (remove) + for (let i = 0; i < _markers.length; i++) { + const mx = r.x + (_markers[i].fx / r.fw) * r.w; + const my = r.y + (_markers[i].fy / r.fh) * r.h; + if (Math.hypot(cxv - mx, cyv - my) <= MARK_HIT_PX) { + _markers.splice(i, 1); drawMarkers(); updateMarkActions(); return; + } + } + // add — convert canvas px -> frame-space px -> stage µm + if (cxv < r.x || cxv > r.x + r.w || cyv < r.y || cyv > r.y + r.h) return; + const fx = ((cxv - r.x) / r.w) * r.fw; + const fy = ((cyv - r.y) / r.h) * r.fh; + const stage = frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: stage[0], stageY: stage[1], source: 'manual' }); + drawMarkers(); updateMarkActions(); + } + function updateMarkActions() { + const n = _markers.length; + _markCount.textContent = `(${n})`; + _confirmBtn.disabled = !_marking || n === 0; + _clearBtn.disabled = !_marking || n === 0; + } + async function runDetect() { + _detectBtn.disabled = true; _detectBtn.textContent = 'Detecting…'; + try { + const data = await postJSON('/api/devices/detect_embryos', {}); + const cands = Array.isArray(data.embryos) ? data.embryos : []; + if (_lastFrame && data.stage_position) _lastFrame.stage_position = data.stage_position; + if (enterMarking(cands)) toast(`Detected ${cands.length} candidate${cands.length === 1 ? '' : 's'} — review & confirm`); + } catch (err) { + toast(`Detect failed (${err.status || err.message})`); + } finally { + _detectBtn.disabled = false; _detectBtn.textContent = 'Detect'; + } + } + async function confirmMarks() { + if (!_markers.length) return; + _confirmBtn.disabled = true; + try { + const markers = _markers.map(m => ({ stage_x_um: m.stageX, stage_y_um: m.stageY })); + const data = await postJSON('/api/devices/embryos/confirm', { markers }); + const n = (data.registered || []).length; + toast(`Registered ${n} embryo${n === 1 ? '' : 's'}`); + exitMarking(); // list refreshes via EMBRYOS_UPDATE + } catch (err) { + toast(`Confirm failed (${err.status || err.message})`); + _confirmBtn.disabled = false; + } + } + + // ---- Embryo list (single source of truth) --------------------------- + function resolveXY(emb) { + const f = emb && emb.position_fine; + if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) return { x: f.x, y: f.y }; + const c = emb && emb.position_coarse; + if (c && Number.isFinite(c.x) && Number.isFinite(c.y)) return { x: c.x, y: c.y }; + return null; + } + function labelFor(emb, i) { + const m = emb.id && String(emb.id).match(/(\d+)/); + return m ? m[1] : String(i + 1); + } + function onEmbryosUpdate(p) { + _embryos = (p && Array.isArray(p.embryos)) ? p.embryos : []; + // prune state for embryos that no longer exist + const ids = new Set(_embryos.map(e => e.id)); + Object.keys(_states).forEach(id => { if (!ids.has(id)) delete _states[id]; }); + _embryos.forEach(e => { if (!_states[e.id]) _states[e.id] = 'marked'; }); + if (_selected && !ids.has(_selected)) selectEmbryo(null); + renderList(); + } + function renderList() { + if (!_embryoList) return; + _embryoCount.textContent = _embryos.length ? `(${_embryos.length})` : ''; + _embryoList.innerHTML = ''; + if (!_embryos.length) { + const e = document.createElement('div'); e.className = 'op-empty'; + e.textContent = 'No embryos yet — mark some in step 1.'; + _embryoList.appendChild(e); return; + } + _embryos.forEach((emb, i) => { + const xy = resolveXY(emb); + const row = document.createElement('div'); + row.className = 'op-erow' + (emb.id === _selected ? ' op-erow-sel' : ''); + row.addEventListener('click', () => selectEmbryo(emb.id)); + + const dot = document.createElement('span'); + dot.className = 'op-edot'; dot.textContent = labelFor(emb, i); + dot.style.background = _ROLE_COLOR[emb.role] || _ROLE_COLOR.unassigned; + row.appendChild(dot); + + const coord = document.createElement('span'); + coord.className = 'op-ecoord'; + coord.textContent = xy ? `(${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` : 'no position'; + row.appendChild(coord); + + const chip = document.createElement('span'); + const st = _states[emb.id] || 'marked'; + chip.className = `op-echip op-echip-${st}`; + chip.textContent = _STATE_LABEL[st] || st; + row.appendChild(chip); + + _embryoList.appendChild(row); + }); + } + function setState(id, st) { if (id) { _states[id] = st; renderList(); } } + + // ---- Acquire (selected embryo) -------------------------------------- + function selectEmbryo(id) { + _selected = id; + const emb = _embryos.find(e => e.id === id); + _selLabel.textContent = emb ? `— embryo ${labelFor(emb, _embryos.indexOf(emb))}` : '— select an embryo'; + _centerBtn.disabled = !emb; + _acquireBtn.disabled = !emb || (_states[id] !== 'focused' && _states[id] !== 'imaged'); + renderList(); + } + async function centerOnSelected() { + const emb = _embryos.find(e => e.id === _selected); + const xy = emb ? resolveXY(emb) : null; + if (!xy) return; + _centerBtn.disabled = true; _centerBtn.textContent = 'Centering…'; + try { + await postJSON('/api/devices/stage/move', { x: xy.x, y: xy.y }); + setState(_selected, 'centered'); + toast(`Centred on embryo ${labelFor(emb, _embryos.indexOf(emb))}`); + } catch (err) { + toast(`Center failed (${err.status || err.message})`); + } finally { + _centerBtn.disabled = false; _centerBtn.textContent = 'Center stage on embryo'; + } + } + async function acquireSelected() { + if (!_selected) return; + _acquireBtn.disabled = true; _acquireBtn.textContent = 'Acquiring…'; + try { + await postJSON('/api/devices/acquire/volume', { num_slices: 50, exposure_ms: 10.0 }); + setState(_selected, 'imaged'); + toast('Volume acquired'); + } catch (err) { + toast(`Acquire failed (${err.status || err.message})`); + } finally { + _acquireBtn.disabled = false; _acquireBtn.textContent = 'Acquire volume'; + } + } + + // ---- Focus Z nudges (fenced) ---------------------------------------- + async function nudgeBottomZ(delta) { + try { + const d = await postJSON('/api/devices/stage/bottom_z/nudge', { delta }); + if (d.position != null) _bzPos.textContent = Number(d.position).toFixed(1); + } catch (err) { toast(`Bottom-Z nudge blocked (${err.status || err.message})`); } + } + async function nudgeFdrive(delta) { + try { + const d = await postJSON('/api/devices/spim/fdrive/nudge', { delta }); + if (d.position != null) _fdPos.textContent = Number(d.position).toFixed(1); + if (d.distance_to_floor != null) _fdFloor.textContent = Number(d.distance_to_floor).toFixed(0); + if (_selected) setState(_selected, 'focused'); + selectEmbryo(_selected); // re-eval acquire enable + } catch (err) { toast(`F-drive nudge blocked (${err.status || err.message})`); } + } + async function refreshZReadouts() { + try { const b = await getJSON('/api/devices/stage/bottom_z'); if (b.position != null) _bzPos.textContent = Number(b.position).toFixed(1); } catch (_) {} + try { + const f = await getJSON('/api/devices/spim/fdrive'); + if (f.position != null) _fdPos.textContent = Number(f.position).toFixed(1); + if (f.distance_to_floor != null) _fdFloor.textContent = Number(f.distance_to_floor).toFixed(0); + } catch (_) {} + } + + // ---- SPIM focus ----------------------------------------------------- + async function toggleSpim() { + _spimToggle.disabled = true; + try { + const ep = _spimOn ? '/api/devices/lightsheet/live/stop' : '/api/devices/lightsheet/live/start'; + const data = await postJSON(ep, {}); + _spimOn = !!data.streaming; + _spimToggle.textContent = _spimOn ? 'Stop view' : 'Start view'; + _spimToggle.classList.toggle('op-btn-on', _spimOn); + if (!_spimOn) { _spimImg.classList.remove('has-frame'); _spimPh.style.display = ''; } + else _spimPh.textContent = 'waiting…'; + } catch (err) { toast(`SPIM view toggle failed (${err.status || err.message})`); } + finally { _spimToggle.disabled = false; } + } + function onLightsheetFrame(p) { + if (!p || !p.jpeg_b64) return; + _spimImg.src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!_spimImg.classList.contains('has-frame')) { _spimImg.classList.add('has-frame'); _spimPh.style.display = 'none'; } + if (p.focus_score != null) _spimScore.textContent = Number(p.focus_score).toFixed(3); + } + let _lsTimer = null; + function postLsParams() { + if (_lsTimer) clearTimeout(_lsTimer); + _lsTimer = setTimeout(() => { + postJSON('/api/devices/lightsheet/live/params', + { galvo: _galvo, piezo: _piezo, exposure: 20, side: 'A' }).catch(() => {}); + }, 120); + } + function nudgeGalvo(d) { _galvo = Math.max(-5, Math.min(5, _galvo + d)); _gvVal.textContent = _galvo.toFixed(1); postLsParams(); } + function nudgePiezo(d) { _piezo = Math.max(0, Math.min(200, _piezo + d)); _pzVal.textContent = _piezo.toFixed(0); postLsParams(); } + async function toggleLed() { + _ledOn = !_ledOn; + _ledBtn.classList.toggle('op-btn-on', _ledOn); _ledBtn.setAttribute('aria-pressed', _ledOn ? 'true' : 'false'); + try { await postJSON('/api/devices/led/set', { state: _ledOn ? 'Open' : 'Closed' }); } + catch (err) { toast(`LED failed (${err.status || err.message})`); } + } + + // ---- Wiring / lifecycle -------------------------------------------- + function wire() { + if (_wired) return; _wired = true; + cacheDom(); + _camToggle.addEventListener('click', toggleCamera); + _markCanvas.addEventListener('click', onCanvasClick); + _detectBtn.addEventListener('click', runDetect); + _confirmBtn.addEventListener('click', confirmMarks); + _clearBtn.addEventListener('click', () => { _markers = []; drawMarkers(); updateMarkActions(); }); + _bzNudge.addEventListener('click', e => { const b = e.target.closest('[data-bz]'); if (b) nudgeBottomZ(Number(b.dataset.bz)); }); + _fdNudge.addEventListener('click', e => { const b = e.target.closest('[data-fd]'); if (b) nudgeFdrive(Number(b.dataset.fd)); }); + _centerBtn.addEventListener('click', centerOnSelected); + _acquireBtn.addEventListener('click', acquireSelected); + _spimToggle.addEventListener('click', toggleSpim); + _ledBtn.addEventListener('click', toggleLed); + document.querySelectorAll('[data-gv]').forEach(b => b.addEventListener('click', () => nudgeGalvo(Number(b.dataset.gv)))); + document.querySelectorAll('[data-pz]').forEach(b => b.addEventListener('click', () => nudgePiezo(Number(b.dataset.pz)))); + window.addEventListener('resize', () => { if (_marking) drawMarkers(); }); + + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('BOTTOM_CAMERA_FRAME', onBottomFrame); + ClientEventBus.on('LIGHTSHEET_FRAME', onLightsheetFrame); + ClientEventBus.on('EMBRYOS_UPDATE', onEmbryosUpdate); + ClientEventBus.on('EMBRYO_DETECTED', () => {}); // bulk EMBRYOS_UPDATE covers it + ClientEventBus.on('DEVICE_STATE_UPDATE', p => { + const pos = p && p.positions; + if (pos && pos.xy_stage && Array.isArray(pos.xy_stage)) _lastXY = { X: pos.xy_stage[0], Y: pos.xy_stage[1] }; + }); + } + } + + async function activate() { + wire(); + if (_active) return; + _active = true; + // Bootstrap the canonical list (mid-session open). + try { const s = await getJSON('/api/embryos/current'); onEmbryosUpdate(s); } catch (_) {} + refreshZReadouts(); + } + function deactivate() { + if (!_active) return; + _active = false; + // Stop streams so they don't run while the view is hidden. + if (_camOn) { fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); applyCameraState(false); } + if (_spimOn) { fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); _spimOn = false; _spimToggle.textContent = 'Start view'; _spimToggle.classList.remove('op-btn-on'); } + if (_marking) exitMarking(); + } + + return { activate, deactivate }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 62c79e24..a864a027 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -19,6 +19,7 @@ + {% if ux_v2 %} @@ -444,6 +445,7 @@

    Notebook

    Device state

    + @@ -482,6 +484,100 @@

    Device

    + + +
    @@ -1152,6 +1248,7 @@