NeuroScope / backend /server.py
Gaurav711's picture
feat: Migrate NeuroScope v2 to Firebase and Google Gemini Explainer
ca6c0e5
Raw
History Blame Contribute Delete
20 kB
"""NeuroScope FastAPI server — mounts /api/v1/* endpoints.
FIX #9 (complete) — MongoDB/Motor replaced with Supabase Postgres client.
All db.collection.* calls replaced with sb.table("*").*.execute() equivalents.
Additional modernisations:
- asyncio.get_running_loop() (replaces deprecated get_event_loop())
- lifespan context manager (replaces deprecated @app.on_event)
- patch_layer validation: 0–25 (26 layers for Gemma-2-2b-it)
- ZeroGPU @spaces.GPU guard (try/except for local dev compatibility)
- Attribution route updated to use sae_coactivation_graph naming
"""
from __future__ import annotations
import asyncio
import logging
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Optional
from dotenv import load_dotenv
from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.middleware.cors import CORSMiddleware
from neuroscope.firebase_init import get_db
from pathlib import Path
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / ".env", override=True)
from neuroscope import llm as ns_llm # noqa: E402
from neuroscope.agent import build_prompt # noqa: E402
from neuroscope.loader import get_model, model_info # noqa: E402
from neuroscope.patching import cross_step_patch # noqa: E402
from neuroscope.runner import attribution_for_step, patch_matrix, run_trajectory # noqa: E402
# Optional ZeroGPU decorator (only available inside a HF Space)
try:
import spaces # type: ignore
_HAS_ZEROGPU = True
except ImportError:
_HAS_ZEROGPU = False
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("neuroscope.server")
# ---------------------------------------------------------------------------
# App + lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
# Nothing to explicitly close with supabase-py sync client
app = FastAPI(title="NeuroScope API", version="2.0.0", lifespan=lifespan)
api_router = APIRouter(prefix="/api")
v1 = APIRouter(prefix="/v1")
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class RunCreate(BaseModel):
task: str
n_steps: int = Field(default=3, ge=2, le=6)
sae_layer: int = Field(default=12, ge=0, le=25) # 26 layers for Gemma-2-2b-it
inject_observation: Optional[dict] = None
class PatchRequest(BaseModel):
source_step: int
target_step: int
patch_layer: int
class PatchSweepRequest(BaseModel):
layers: Optional[list[int]] = None
class QueryRequest(BaseModel):
query: str
class AttributionRequest(BaseModel):
step_n: int
layer: int = 12 # Default to layer 12 (mid-model for Gemma-2-2b-it)
top_k: int = 12
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
async def _get_run(run_id: str) -> dict:
db = get_db()
loop = asyncio.get_running_loop()
def _fetch():
# Try agent_runs first
doc_ref = db.collection("agent_runs").document(run_id).get()
if doc_ref.exists:
return doc_ref.to_dict()
# Fall back to experiments (view by slug)
query = db.collection("experiments").where("slug", "==", run_id).limit(1).get()
if query:
e = query[0].to_dict()
return {
"id": e["slug"],
"task": e.get("task"),
"n_steps": e.get("n_steps"),
"sae_layer": e.get("sae_layer"),
"model": e.get("model", "gemma-2-2b-it"),
"status": "done",
"steps": e.get("steps", []),
"feature_timelines": e.get("feature_timelines", []),
"patch_matrix": e.get("patch_matrix", []),
"patch_matrix_summary": e.get("patch_matrix_summary"),
"total_elapsed_ms": e.get("total_elapsed_ms"),
"_is_experiment": True,
}
return None
doc = await loop.run_in_executor(None, _fetch)
if not doc:
raise HTTPException(status_code=404, detail="run not found")
return doc
def _summary_for_llm(run: dict, max_steps: int = 8) -> dict:
"""Build a compact JSON the LLM can ground answers in."""
return {
"task": run["task"],
"model": run.get("model", "gemma-2-2b-it"),
"n_steps": run["n_steps"],
"sae_layer": run["sae_layer"],
"capture_layers": run.get("capture_layers", [6, 12, 18, 24]),
"steps": [
{
"step_n": s["step_n"],
"output": s["output"][:160],
"tool_called": s.get("tool_called"),
"top_features": s["top_features"][:5],
"hallucination": s["hallucination"],
"layer_l2_norms_sample": s["layer_l2_norms"],
}
for s in run.get("steps", [])[:max_steps]
],
"top_drifting_features": [
{"feature_id": f["feature_id"], "drift_score": f["drift_score"], "activations": f["activations"]}
for f in run.get("feature_timelines", [])[:8]
],
"patch_summary": run.get("patch_matrix_summary"),
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@v1.get("/health")
async def health():
return {"status": "ok", "time": _now(), "model": model_info()}
@v1.get("/suggested-tasks")
async def suggested_tasks():
return {
"tasks": [
{
"id": "capital-france",
"title": "Factual multi-hop",
"task": "The Eiffel Tower is located in which city, and what country is that city the capital of?",
"n_steps": 3,
"category": "Factual QA",
},
{
"id": "math-stepwise",
"title": "Stepwise arithmetic",
"task": "If a train leaves at 14:30 and travels for 2 hours 45 minutes, what time does it arrive?",
"n_steps": 4,
"category": "Math",
},
{
"id": "tool-routing",
"title": "Tool routing decision",
"task": "Which tool should you use to find the current weather in Paris: search, lookup, or calc?",
"n_steps": 3,
"category": "Tool selection",
},
{
"id": "self-correction",
"title": "Self-correction",
"task": "Compute 23 * 17. Then verify by computing 17 * 23 and check the results match.",
"n_steps": 4,
"category": "Self-correction",
},
{
"id": "reasoning-collapse",
"title": "Multi-hop reasoning",
"task": "Who is the current spouse of the president of the country whose capital hosts the Eiffel Tower?",
"n_steps": 5,
"category": "Multi-hop",
},
{
"id": "ambiguity",
"title": "Ambiguity probe",
"task": "Is the statement 'The bank is across the river' about a financial institution or a riverbank?",
"n_steps": 3,
"category": "Ambiguity",
},
]
}
@v1.post("/runs")
async def create_run(payload: RunCreate, background_tasks: BackgroundTasks):
run_id = str(uuid.uuid4())
doc = {
"id": run_id,
"task": payload.task,
"n_steps": payload.n_steps,
"sae_layer": payload.sae_layer,
"model_name": "gemma-2-2b-it",
"status": "queued",
"created_at": _now(),
"progress": {"stage": "queued", "completed_steps": 0},
"inject_observation": payload.inject_observation or {},
}
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: get_db().collection("agent_runs").document(run_id).set(doc))
background_tasks.add_task(
_execute_run, run_id, payload.task, payload.n_steps, payload.sae_layer,
payload.inject_observation or {},
)
return {"run_id": run_id, "status": "queued"}
# ZeroGPU-decorated trajectory function (guard for local dev)
if _HAS_ZEROGPU:
import spaces # type: ignore
@spaces.GPU(duration=120)
def _run_trajectory_gpu(run_id, task, n_steps, sae_layer, inject):
return run_trajectory(
run_id=run_id, task=task, n_steps=n_steps,
sae_layer=sae_layer, inject_context_at_step=inject,
)
else:
def _run_trajectory_gpu(run_id, task, n_steps, sae_layer, inject):
return run_trajectory(
run_id=run_id, task=task, n_steps=n_steps,
sae_layer=sae_layer, inject_context_at_step=inject,
)
async def _execute_run(run_id: str, task: str, n_steps: int, sae_layer: int, inject: dict) -> None:
"""Background task: execute trajectory, persist to Firestore."""
inject_int = {int(k): v for k, v in (inject or {}).items()}
db = get_db()
loop = asyncio.get_running_loop()
async def _update(fields: dict):
await loop.run_in_executor(
None,
lambda: db.collection("agent_runs").document(run_id).update(fields),
)
try:
await _update({"status": "running", "progress": {"stage": "loading_model", "completed_steps": 0}})
def _on_progress(stage: str, payload: dict):
asyncio.run_coroutine_threadsafe(
_update({"progress": {"stage": stage, "completed_steps": payload.get("step_n", 0)}}),
loop,
)
result = await loop.run_in_executor(
None,
lambda: _run_trajectory_gpu(run_id, task, n_steps, sae_layer, inject_int or None),
)
await _update({
"status": "done",
"steps": result["steps"],
"feature_timelines": result["feature_timelines"],
"total_elapsed_ms": result["total_elapsed_ms"],
"progress": {"stage": "done", "completed_steps": n_steps},
})
logger.info("Run %s done in %dms", run_id, result["total_elapsed_ms"])
except Exception as e: # noqa: BLE001
logger.exception("Run %s failed", run_id)
await _update({"status": "error", "error": f"{type(e).__name__}: {e}"})
@v1.get("/runs")
async def list_runs():
db = get_db()
from firebase_admin import firestore
loop = asyncio.get_running_loop()
def _fetch():
docs = db.collection("agent_runs").order_by("created_at", direction=firestore.Query.DESCENDING).limit(30).stream()
runs = []
for doc in docs:
d = doc.to_dict()
runs.append({
"id": d.get("id"),
"task": d.get("task"),
"model_name": d.get("model_name"),
"status": d.get("status"),
"created_at": d.get("created_at"),
"n_steps": d.get("n_steps"),
"sae_layer": d.get("sae_layer"),
"progress": d.get("progress"),
"error": d.get("error"),
"total_elapsed_ms": d.get("total_elapsed_ms"),
})
return runs
rows = await loop.run_in_executor(None, _fetch)
return {"runs": rows}
@v1.get("/runs/{run_id}")
async def get_run(run_id: str):
return await _get_run(run_id)
@v1.get("/runs/{run_id}/steps/{step_n}")
async def get_step(run_id: str, step_n: int):
run = await _get_run(run_id)
for s in run.get("steps", []):
if s["step_n"] == step_n:
return s
raise HTTPException(status_code=404, detail="step not found")
@v1.post("/runs/{run_id}/patch")
async def run_patch(run_id: str, payload: PatchRequest):
run = await _get_run(run_id)
src = next((s for s in run.get("steps", []) if s["step_n"] == payload.source_step), None)
tgt = next((s for s in run.get("steps", []) if s["step_n"] == payload.target_step), None)
if not src or not tgt:
raise HTTPException(status_code=404, detail="step missing")
if not (0 <= payload.patch_layer <= 25):
raise HTTPException(status_code=400, detail="patch_layer must be 0–25 (Gemma-2-2b-it has 26 layers)")
model = get_model()
loop = asyncio.get_running_loop()
res = await loop.run_in_executor(
None, cross_step_patch,
model, src["activation_path"], tgt["prompt"], payload.patch_layer,
)
record = {
"id": str(uuid.uuid4()),
"run_id": run_id,
"source_step": payload.source_step,
"target_step": payload.target_step,
"patch_layer": payload.patch_layer,
**res,
"created_at": _now(),
}
db = get_db()
loop2 = asyncio.get_running_loop()
await loop2.run_in_executor(None, lambda: db.collection("causal_patches").document(record["id"]).set(record))
return record
@v1.post("/runs/{run_id}/patch-matrix")
async def run_patch_matrix(run_id: str, payload: PatchSweepRequest):
run = await _get_run(run_id)
steps = run.get("steps", [])
if not steps:
raise HTTPException(status_code=400, detail="run not done")
def target_prompt_fn(step_n: int) -> str:
for s in steps:
if s["step_n"] == step_n:
return s["prompt"]
return ""
loop = asyncio.get_running_loop()
layers = payload.layers or [6, 12, 18] # Gemma-2-2b-it captured layers
results = await loop.run_in_executor(None, patch_matrix, steps, target_prompt_fn, layers)
summary = {
"layers": layers,
"n_results": len(results),
"max_kl": max((r["kl"] for r in results), default=0.0),
"significant_count": sum(1 for r in results if r["significant"]),
}
db = get_db()
await loop.run_in_executor(
None,
lambda: db.collection("agent_runs").document(run_id).update({
"patch_matrix": results,
"patch_matrix_summary": summary,
}),
)
return {"patch_matrix": results, "layers": layers}
@v1.get("/runs/{run_id}/patches")
async def list_patches(run_id: str):
db = get_db()
from firebase_admin import firestore
loop = asyncio.get_running_loop()
def _fetch():
docs = db.collection("causal_patches").where("run_id", "==", run_id).order_by("created_at", direction=firestore.Query.DESCENDING).limit(200).stream()
return [doc.to_dict() for doc in docs]
rows = await loop.run_in_executor(None, _fetch)
return {"patches": rows}
@v1.post("/runs/{run_id}/attribution")
async def run_attribution(run_id: str, payload: AttributionRequest):
run = await _get_run(run_id)
step = next((s for s in run.get("steps", []) if s["step_n"] == payload.step_n), None)
if not step:
raise HTTPException(status_code=404, detail="step not found")
loop = asyncio.get_running_loop()
graph = await loop.run_in_executor(
None, attribution_for_step, step["activation_path"], payload.layer, payload.top_k,
)
record = {
"id": str(uuid.uuid4()),
"run_id": run_id,
"step_n": payload.step_n,
"layer": payload.layer,
"graph": graph,
"created_at": _now(),
}
db = get_db()
await loop.run_in_executor(None, lambda: db.collection("attribution_graphs").document(record["id"]).set(record))
return record
@v1.post("/runs/{run_id}/query")
async def run_query(run_id: str, payload: QueryRequest):
run = await _get_run(run_id)
ctx = _summary_for_llm(run)
answer = await ns_llm.ask(payload.query, ctx, session_id=f"run-{run_id}")
record = {
"id": str(uuid.uuid4()),
"run_id": run_id,
"query": payload.query,
"answer": answer,
"created_at": _now(),
}
db = get_db()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: db.collection("queries").document(record["id"]).set(record))
return record
@v1.get("/runs/{run_id}/queries")
async def list_queries(run_id: str):
db = get_db()
from firebase_admin import firestore
loop = asyncio.get_running_loop()
def _fetch():
docs = db.collection("queries").where("run_id", "==", run_id).order_by("created_at", direction=firestore.Query.DESCENDING).limit(50).stream()
return [doc.to_dict() for doc in docs]
rows = await loop.run_in_executor(None, _fetch)
return {"queries": rows}
@v1.get("/experiments")
async def list_experiments():
db = get_db()
loop = asyncio.get_running_loop()
def _fetch():
docs = db.collection("experiments").stream()
experiments = []
for doc in docs:
d = doc.to_dict()
experiments.append({
"id": d.get("id"),
"slug": d.get("slug"),
"title": d.get("title"),
"category": d.get("category"),
"hypothesis": d.get("hypothesis"),
"finding": d.get("finding"),
"task": d.get("task"),
"n_steps": d.get("n_steps"),
"sae_layer": d.get("sae_layer"),
"model": d.get("model"),
"total_elapsed_ms": d.get("total_elapsed_ms"),
"created_at": d.get("created_at"),
})
return experiments
rows = await loop.run_in_executor(None, _fetch)
return {"experiments": rows}
@v1.get("/experiments/{slug}")
async def get_experiment(slug: str):
db = get_db()
loop = asyncio.get_running_loop()
def _fetch():
query = db.collection("experiments").where("slug", "==", slug).limit(1).get()
return [doc.to_dict() for doc in query]
rows = await loop.run_in_executor(None, _fetch)
if not rows:
raise HTTPException(status_code=404, detail="experiment not found")
return rows[0]
@v1.get("/feature/{layer}/{feature_id}")
async def get_feature(layer: int, feature_id: int):
"""Return cached GemmaScope feature label (best-effort via Neuronpedia)."""
db = get_db()
loop = asyncio.get_running_loop()
def _fetch():
doc_id = f"l{layer}_f{feature_id}"
doc_ref = db.collection("feature_labels").document(doc_id).get()
if doc_ref.exists:
return [doc_ref.to_dict()]
return []
rows = await loop.run_in_executor(None, _fetch)
if rows:
return rows[0]
return {
"layer": layer,
"feature_id": feature_id,
"label": f"gemma_l{layer}_f{feature_id}",
"neuronpedia_url": f"https://www.neuronpedia.org/gemma-2-2b/{layer}-gemmascope-res-16k/{feature_id}",
"description": "Label not yet cached. Click 'View on Neuronpedia' for community-sourced description.",
}
# ---------------------------------------------------------------------------
# Mount
# ---------------------------------------------------------------------------
api_router.include_router(v1)
@api_router.get("/")
async def root():
return {"service": "neuroscope", "version": "2.0.0", "model": "gemma-2-2b-it"}
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","),
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(_, exc: Exception):
logger.exception("unhandled")
return JSONResponse(status_code=500, content={"detail": f"{type(exc).__name__}: {exc}"})