Spaces:
Sleeping
Sleeping
| """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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| async def health(): | |
| return {"status": "ok", "time": _now(), "model": model_info()} | |
| 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", | |
| }, | |
| ] | |
| } | |
| 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 | |
| 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}"}) | |
| 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} | |
| async def get_run(run_id: str): | |
| return await _get_run(run_id) | |
| 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") | |
| 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 | |
| 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} | |
| 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} | |
| 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 | |
| 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 | |
| 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} | |
| 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} | |
| 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] | |
| 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) | |
| 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=["*"], | |
| ) | |
| async def global_exception_handler(_, exc: Exception): | |
| logger.exception("unhandled") | |
| return JSONResponse(status_code=500, content={"detail": f"{type(exc).__name__}: {exc}"}) | |