web1 checkpoint
This commit is contained in:
parent
261da42c00
commit
22ca411210
0
app/web1/__init__.py
Normal file
0
app/web1/__init__.py
Normal file
507
app/web1/main.py
Normal file
507
app/web1/main.py
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
"""
|
||||||
|
web1 — "Scientific instrument / research notebook"
|
||||||
|
|
||||||
|
A FastAPI UI for kicking off the embedding-flow Prefect deployment and
|
||||||
|
viewing the resulting HTML animations.
|
||||||
|
|
||||||
|
Design: restrained, typography-driven, two-column notebook layout. No CSS
|
||||||
|
framework; hand-written styles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths / constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
PROJECT_ROOT = BASE_DIR.parent.parent # /home/mm/work/dr-sandbox
|
||||||
|
FIGS_DIR = PROJECT_ROOT / "figs"
|
||||||
|
FIGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
PREFECT_API = os.environ.get("PREFECT_API_URL", "http://localhost:4200/api")
|
||||||
|
DEPLOYMENT_NAME = "embedding-flow/embedding-flow"
|
||||||
|
|
||||||
|
GENERATOR_OPTIONS = [
|
||||||
|
("sklearn.datasets.make_s_curve", "make_s_curve"),
|
||||||
|
("sklearn.datasets.make_swiss_roll", "make_swiss_roll"),
|
||||||
|
("sklearn.datasets.make_blobs", "make_blobs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reducer catalogue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each field tuple: (name, kind, default, choices_or_none, help_or_none)
|
||||||
|
# kinds: "int", "float", "str", "bool", "str_or_float", "int_or_null"
|
||||||
|
|
||||||
|
REDUCERS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"sklearn.decomposition.PCA": {
|
||||||
|
"pkg": "sklearn",
|
||||||
|
"label": "PCA",
|
||||||
|
"blurb": "Principal component analysis. Linear, fast, deterministic.",
|
||||||
|
"key": [
|
||||||
|
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("svd_solver", "str", "auto", ["auto", "full", "arpack", "randomized"], None),
|
||||||
|
("random_state", "int", 42, None, None),
|
||||||
|
("whiten", "bool", False, None, None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sklearn.decomposition.FactorAnalysis": {
|
||||||
|
"pkg": "sklearn",
|
||||||
|
"label": "FactorAnalysis",
|
||||||
|
"blurb": "Gaussian latent-factor model with per-feature noise.",
|
||||||
|
"key": [
|
||||||
|
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
("random_state", "int", 42, None, None),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("tol", "float", 0.01, None, None),
|
||||||
|
("max_iter", "int", 1000, None, None),
|
||||||
|
("rotation", "str", "", ["", "varimax", "quartimax"], "Empty = None."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sklearn.manifold.TSNE": {
|
||||||
|
"pkg": "sklearn",
|
||||||
|
"label": "t-SNE",
|
||||||
|
"blurb": "Stochastic neighbour embedding. Local structure preserved.",
|
||||||
|
"key": [
|
||||||
|
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
("perplexity", "float", 30.0, None, None),
|
||||||
|
("random_state", "int", 42, None, None),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("learning_rate", "str_or_float", "auto", None, "'auto' or a float."),
|
||||||
|
("n_iter", "int", 1000, None, None),
|
||||||
|
("metric", "str", "euclidean", None, None),
|
||||||
|
("early_exaggeration", "float", 12.0, None, None),
|
||||||
|
("init", "str", "pca", ["pca", "random"], None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"umap.UMAP": {
|
||||||
|
"pkg": "umap",
|
||||||
|
"label": "UMAP",
|
||||||
|
"blurb": "Uniform manifold approximation. Preserves local + some global structure.",
|
||||||
|
"key": [
|
||||||
|
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
("n_neighbors", "int", 15, None, None),
|
||||||
|
("min_dist", "float", 0.1, None, None),
|
||||||
|
("random_state", "int", 42, None, None),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("metric", "str", "euclidean", None, None),
|
||||||
|
("n_epochs", "int_or_null", "", None, "Empty = None (auto)."),
|
||||||
|
("spread", "float", 1.0, None, None),
|
||||||
|
("init", "str", "spectral", ["spectral", "random"], None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"pacmap.PaCMAP": {
|
||||||
|
"pkg": "pacmap",
|
||||||
|
"label": "PaCMAP",
|
||||||
|
"blurb": "Pairwise-controlled manifold approximation. Balanced local/global.",
|
||||||
|
"key": [
|
||||||
|
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
("n_neighbors", "int", 10, None, None),
|
||||||
|
("MN_ratio", "float", 0.5, None, None),
|
||||||
|
("FP_ratio", "float", 2.0, None, None),
|
||||||
|
("random_state", "int", 42, None, None),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("lr", "float", 1.0, None, None),
|
||||||
|
("num_iters", "int", 450, None, None),
|
||||||
|
("apply_pca", "bool", True, None, None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"trimap.TRIMAP": {
|
||||||
|
"pkg": "trimap",
|
||||||
|
"label": "TriMap",
|
||||||
|
"blurb": "Triplet-based dimensionality reduction. Emphasises global structure.",
|
||||||
|
"key": [
|
||||||
|
("n_dims", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
|
||||||
|
("n_inliers", "int", 10, None, None),
|
||||||
|
("n_outliers", "int", 5, None, None),
|
||||||
|
("n_random", "int", 5, None, None),
|
||||||
|
],
|
||||||
|
"advanced": [
|
||||||
|
("lr", "float", 0.1, None, None),
|
||||||
|
("n_iters", "int", 400, None, None),
|
||||||
|
("weight_adj", "float", 500.0, None, None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available_reducers() -> List[Tuple[str, Dict[str, Any]]]:
|
||||||
|
out = []
|
||||||
|
for key, spec in REDUCERS.items():
|
||||||
|
if importlib.util.find_spec(spec["pkg"]) is not None:
|
||||||
|
out.append((key, spec))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parameter coercion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(kind: str, raw: Optional[str], default: Any) -> Any:
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
s = raw.strip() if isinstance(raw, str) else raw
|
||||||
|
if kind == "int":
|
||||||
|
if s == "" or s is None:
|
||||||
|
return default
|
||||||
|
return int(s)
|
||||||
|
if kind == "float":
|
||||||
|
if s == "" or s is None:
|
||||||
|
return default
|
||||||
|
return float(s)
|
||||||
|
if kind == "bool":
|
||||||
|
# Checkbox: "on" / absent
|
||||||
|
return bool(s) and s not in ("0", "false", "False", "")
|
||||||
|
if kind == "str":
|
||||||
|
if s == "":
|
||||||
|
return None if default in (None, "") else default if default else ""
|
||||||
|
return s
|
||||||
|
if kind == "str_or_float":
|
||||||
|
if s == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return s
|
||||||
|
if kind == "int_or_null":
|
||||||
|
if s == "":
|
||||||
|
return None
|
||||||
|
return int(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def build_embed_args(reducer_key: str, form: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
spec = REDUCERS[reducer_key]
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
all_fields = list(spec["key"]) + list(spec["advanced"])
|
||||||
|
for (name, kind, default, _choices, _help) in all_fields:
|
||||||
|
raw = form.get(f"embed__{name}")
|
||||||
|
if kind == "bool":
|
||||||
|
raw_v = "on" if f"embed__{name}" in form else ""
|
||||||
|
value = bool(raw_v)
|
||||||
|
else:
|
||||||
|
value = _coerce(kind, raw, default)
|
||||||
|
# Null-stripping: drop empty rotations etc.
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and value == "" and default in (None, ""):
|
||||||
|
continue
|
||||||
|
out[name] = value
|
||||||
|
|
||||||
|
# Always force n_components / n_dims to 2 (flow assertion)
|
||||||
|
if "n_components" in out:
|
||||||
|
out["n_components"] = 2
|
||||||
|
if "n_dims" in out:
|
||||||
|
out["n_dims"] = 2
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output-path synthesis (mirrors flows/embedding_flow.py lines ~162–168)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_output_paths(
|
||||||
|
generator_path: str,
|
||||||
|
embedder: str,
|
||||||
|
num_points: int,
|
||||||
|
num_snapshots: int,
|
||||||
|
jitter_scale: float,
|
||||||
|
seed: int,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
gen = generator_path.split(".")[-1]
|
||||||
|
emb = embedder.split(".")[-1]
|
||||||
|
ref = f"{gen}_Reference_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
|
||||||
|
embf = f"{gen}_{emb}_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
|
||||||
|
return ref, embf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prefect client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Prefect:
|
||||||
|
def __init__(self, base: str = PREFECT_API) -> None:
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self._deployment_id: Optional[str] = None
|
||||||
|
|
||||||
|
async def deployment_id(self, client: httpx.AsyncClient) -> Optional[str]:
|
||||||
|
if self._deployment_id:
|
||||||
|
return self._deployment_id
|
||||||
|
try:
|
||||||
|
r = await client.get(f"{self.base}/deployments/name/{DEPLOYMENT_NAME}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
self._deployment_id = r.json()["id"]
|
||||||
|
return self._deployment_id
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_run(
|
||||||
|
self, client: httpx.AsyncClient, parameters: Dict[str, Any]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
dep = await self.deployment_id(client)
|
||||||
|
if not dep:
|
||||||
|
return None
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/deployments/{dep}/create_flow_run",
|
||||||
|
json={"parameters": parameters},
|
||||||
|
)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return {"error": r.text, "status": r.status_code}
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def recent_runs(
|
||||||
|
self, client: httpx.AsyncClient, limit: int = 10
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
dep = await self.deployment_id(client)
|
||||||
|
if not dep:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/flow_runs/filter",
|
||||||
|
json={
|
||||||
|
"sort": "START_TIME_DESC",
|
||||||
|
"limit": limit,
|
||||||
|
"flow_runs": {"deployment_id": {"any_": [dep]}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
PREFECT = Prefect()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory mapping: flow_run_id -> synthesized output file names
|
||||||
|
# (best-effort; lost on restart, which is fine)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RUN_OUTPUTS: Dict[str, Dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(title="web1 — embedding notebook", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
app.mount("/figs", StaticFiles(directory=str(FIGS_DIR)), name="figs")
|
||||||
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Normalise a flow-run dict for the template."""
|
||||||
|
rid = run.get("id", "")
|
||||||
|
state_type = (run.get("state_type") or "PENDING").upper()
|
||||||
|
state_name = run.get("state_name") or state_type.title()
|
||||||
|
start = run.get("start_time") or run.get("expected_start_time") or run.get("created")
|
||||||
|
params = run.get("parameters") or {}
|
||||||
|
# Try to look up synthesised outputs either from memory or from params
|
||||||
|
ref_file = None
|
||||||
|
emb_file = None
|
||||||
|
outs = RUN_OUTPUTS.get(rid)
|
||||||
|
if outs:
|
||||||
|
ref_file = outs["ref"]
|
||||||
|
emb_file = outs["embed"]
|
||||||
|
elif params:
|
||||||
|
try:
|
||||||
|
ref_file, emb_file = synthesize_output_paths(
|
||||||
|
params.get("generator_path", "sklearn.datasets.make_s_curve"),
|
||||||
|
params.get("embedder", "sklearn.decomposition.FactorAnalysis"),
|
||||||
|
int(params.get("num_points", 5000)),
|
||||||
|
int(params.get("num_snapshots", 48)),
|
||||||
|
float(params.get("jitter_scale", 0.01)),
|
||||||
|
int(params.get("seed", 42)),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
ref_file, emb_file = None, None
|
||||||
|
|
||||||
|
ref_exists = bool(ref_file) and (FIGS_DIR / ref_file).exists()
|
||||||
|
emb_exists = bool(emb_file) and (FIGS_DIR / emb_file).exists()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": rid,
|
||||||
|
"short_id": rid[:8] if rid else "",
|
||||||
|
"name": run.get("name", ""),
|
||||||
|
"state_type": state_type,
|
||||||
|
"state_name": state_name,
|
||||||
|
"start": start,
|
||||||
|
"params": params,
|
||||||
|
"ref_file": ref_file,
|
||||||
|
"emb_file": emb_file,
|
||||||
|
"ref_exists": ref_exists,
|
||||||
|
"emb_exists": emb_exists,
|
||||||
|
"embedder_short": (params.get("embedder") or "").split(".")[-1],
|
||||||
|
"generator_short": (params.get("generator_path") or "").split(".")[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reducer_choices() -> List[Dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"key": k, "label": spec["label"], "blurb": spec["blurb"]}
|
||||||
|
for k, spec in available_reducers()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request) -> HTMLResponse:
|
||||||
|
reducers = _reducer_choices()
|
||||||
|
default_reducer = reducers[0]["key"] if reducers else None
|
||||||
|
default_spec = REDUCERS.get(default_reducer) if default_reducer else None
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
runs = await PREFECT.recent_runs(client, limit=10)
|
||||||
|
dep_id = await PREFECT.deployment_id(client)
|
||||||
|
views = [_run_view(r) for r in runs]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"index.html",
|
||||||
|
{
|
||||||
|
"reducers": reducers,
|
||||||
|
"default_reducer": default_reducer,
|
||||||
|
"default_spec": default_spec,
|
||||||
|
"generators": GENERATOR_OPTIONS,
|
||||||
|
"runs": views,
|
||||||
|
"deployment_id": dep_id,
|
||||||
|
"prefect_api": PREFECT_API,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reducer-form", response_class=HTMLResponse)
|
||||||
|
async def reducer_form(request: Request, name: str) -> HTMLResponse:
|
||||||
|
spec = REDUCERS.get(name)
|
||||||
|
if not spec:
|
||||||
|
return HTMLResponse("<p class='err'>unknown reducer</p>", status_code=404)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"_reducer_form.html",
|
||||||
|
{"reducer_key": name, "spec": spec},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/runs", response_class=HTMLResponse)
|
||||||
|
async def runs_partial(request: Request) -> HTMLResponse:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
runs = await PREFECT.recent_runs(client, limit=10)
|
||||||
|
views = [_run_view(r) for r in runs]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "_runs.html", {"runs": views}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/submit", response_class=HTMLResponse)
|
||||||
|
async def submit(request: Request) -> HTMLResponse:
|
||||||
|
form = await request.form()
|
||||||
|
data: Dict[str, str] = {k: str(v) for k, v in form.items()}
|
||||||
|
|
||||||
|
reducer = data.get("reducer") or ""
|
||||||
|
if reducer not in REDUCERS:
|
||||||
|
return HTMLResponse(
|
||||||
|
f"<div class='flash err'>unknown reducer: {reducer}</div>",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Data params
|
||||||
|
try:
|
||||||
|
num_points = int(data.get("num_points", "5000") or 5000)
|
||||||
|
num_snapshots = int(data.get("num_snapshots", "48") or 48)
|
||||||
|
jitter_scale = float(data.get("jitter_scale", "0.01") or 0.01)
|
||||||
|
seed = int(data.get("seed", "42") or 42)
|
||||||
|
except ValueError as e:
|
||||||
|
return HTMLResponse(
|
||||||
|
f"<div class='flash err'>bad numeric input: {e}</div>", status_code=400
|
||||||
|
)
|
||||||
|
generator_path = data.get("generator_path") or "sklearn.datasets.make_s_curve"
|
||||||
|
|
||||||
|
embed_args = build_embed_args(reducer, data)
|
||||||
|
|
||||||
|
generator_kwargs: Dict[str, Any] = {}
|
||||||
|
if generator_path.endswith("make_blobs"):
|
||||||
|
generator_kwargs["n_features"] = 3
|
||||||
|
|
||||||
|
parameters: Dict[str, Any] = {
|
||||||
|
"num_points": num_points,
|
||||||
|
"num_snapshots": num_snapshots,
|
||||||
|
"jitter_scale": jitter_scale,
|
||||||
|
"seed": seed,
|
||||||
|
"generator_path": generator_path,
|
||||||
|
"embedder": reducer,
|
||||||
|
"embed_args": embed_args,
|
||||||
|
}
|
||||||
|
if generator_kwargs:
|
||||||
|
parameters["generator_kwargs"] = generator_kwargs
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
run = await PREFECT.create_run(client, parameters)
|
||||||
|
|
||||||
|
if not run:
|
||||||
|
return HTMLResponse(
|
||||||
|
"<div class='flash err'>could not reach Prefect API at "
|
||||||
|
f"{PREFECT_API}</div>",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
if "error" in run:
|
||||||
|
return HTMLResponse(
|
||||||
|
f"<div class='flash err'>prefect error ({run.get('status')}): "
|
||||||
|
f"<code>{run.get('error')[:500]}</code></div>",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_file, emb_file = synthesize_output_paths(
|
||||||
|
generator_path, reducer, num_points, num_snapshots, jitter_scale, seed
|
||||||
|
)
|
||||||
|
RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
|
||||||
|
|
||||||
|
# Return freshly refreshed runs partial so htmx can swap the right column
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
runs = await PREFECT.recent_runs(client, limit=10)
|
||||||
|
views = [_run_view(r) for r in runs]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"_runs.html",
|
||||||
|
{"runs": views, "just_submitted": run["id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> JSONResponse:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
dep = await PREFECT.deployment_id(client)
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": True, "deployment_id": dep, "prefect_api": PREFECT_API}
|
||||||
|
)
|
||||||
515
app/web1/static/style.css
Normal file
515
app/web1/static/style.css
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* web1 — scientific instrument / research notebook
|
||||||
|
*
|
||||||
|
* Palette:
|
||||||
|
* page #fafaf8 off-white
|
||||||
|
* ink #1a1a1a charcoal
|
||||||
|
* mute #6b6b68 secondary text
|
||||||
|
* faint #b9b8b2 hairlines, disabled
|
||||||
|
* rule #e3e1db dividers
|
||||||
|
* accent #1f4e5f muted deep teal
|
||||||
|
* alarm #8a3a2a rust, for failures (sparingly)
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--page: #fafaf8;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--mute: #6b6b68;
|
||||||
|
--faint: #b9b8b2;
|
||||||
|
--rule: #e3e1db;
|
||||||
|
--rule-2: #ccc9c1;
|
||||||
|
--accent: #1f4e5f;
|
||||||
|
--accent-tint: #e9eff1;
|
||||||
|
--alarm: #8a3a2a;
|
||||||
|
--warm: #a77a2c;
|
||||||
|
--good: #3a6f3f;
|
||||||
|
|
||||||
|
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
|
||||||
|
"Inter", "Arial", sans-serif;
|
||||||
|
--mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas", ui-monospace,
|
||||||
|
monospace;
|
||||||
|
|
||||||
|
--fs-base: 14px;
|
||||||
|
--lh-base: 1.55;
|
||||||
|
--space: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
line-height: var(--lh-base);
|
||||||
|
font-feature-settings: "tnum" 1, "cv11" 1; /* tabular numerals */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, pre, samp, .mono, .num {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-feature-settings: "tnum" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: border-color 120ms ease;
|
||||||
|
}
|
||||||
|
a:hover { border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- shell ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding: 1.6rem 2.2rem 1.2rem;
|
||||||
|
background: var(--page);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.masthead .title {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 1.55rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.masthead .title em {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mute);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.masthead .meta {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--mute);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.masthead .meta .dot {
|
||||||
|
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--good); margin-right: 4px; vertical-align: 1px;
|
||||||
|
}
|
||||||
|
.masthead .meta .dot.bad { background: var(--alarm); }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||||
|
gap: 0;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: calc(100vh - 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-left {
|
||||||
|
padding: 1.8rem 2rem 2rem 2.2rem;
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.col-right {
|
||||||
|
padding: 1.8rem 2.2rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 940px) {
|
||||||
|
main { grid-template-columns: 1fr; }
|
||||||
|
.col-left { border-right: 0; border-bottom: 1px solid var(--rule); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- typography / section labels -------------------------------- */
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.70rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mute);
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.section-label .ordinal {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section { margin-top: 1.9rem; }
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--mute);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
margin: 0.4rem 0 0.9rem;
|
||||||
|
max-width: 44ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- form ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
row-gap: 0.55rem;
|
||||||
|
column-gap: 1.2rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.form-grid label .hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mute);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 24ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input[type="text"],
|
||||||
|
.form-grid input[type="number"],
|
||||||
|
.form-grid select {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--rule-2);
|
||||||
|
padding: 3px 2px 4px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.form-grid input[type="text"]:focus,
|
||||||
|
.form-grid input[type="number"]:focus,
|
||||||
|
.form-grid select:focus {
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: var(--accent-tint);
|
||||||
|
}
|
||||||
|
.form-grid input[readonly] {
|
||||||
|
color: var(--mute);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-style: dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--mute) 50%),
|
||||||
|
linear-gradient(-45deg, transparent 50%, var(--mute) 50%);
|
||||||
|
background-position: calc(100% - 10px) 50%, calc(100% - 5px) 50%;
|
||||||
|
background-size: 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* reducer picker — a typographic list, not a dropdown */
|
||||||
|
.reducer-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.reducer-list li { border-bottom: 1px solid var(--rule); }
|
||||||
|
.reducer-list input[type="radio"] { display: none; }
|
||||||
|
.reducer-list label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4rem 1fr auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.55rem 0.2rem 0.55rem 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 100ms ease;
|
||||||
|
}
|
||||||
|
.reducer-list label:hover { background: var(--accent-tint); }
|
||||||
|
.reducer-list .mark {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--faint);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.reducer-list input:checked + label {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
}
|
||||||
|
.reducer-list input:checked + label .mark { color: var(--accent); }
|
||||||
|
.reducer-list input:checked + label .name { color: var(--accent); font-weight: 600; }
|
||||||
|
.reducer-list .name {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.reducer-list .blurb {
|
||||||
|
display: block;
|
||||||
|
color: var(--mute);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.reducer-list .pkg {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* advanced disclosure */
|
||||||
|
details.advanced {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
border-top: 1px dashed var(--rule);
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
details.advanced > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--mute);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.advanced > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.advanced > summary::before {
|
||||||
|
content: "[ + ]";
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--faint);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
details.advanced[open] > summary::before { content: "[ − ]"; color: var(--accent); }
|
||||||
|
|
||||||
|
.advanced-body { padding-top: 0.5rem; }
|
||||||
|
|
||||||
|
/* submit */
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
button.submit {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--page);
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
border-radius: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
}
|
||||||
|
button.submit:hover { background: #143642; }
|
||||||
|
button.submit:disabled { background: var(--faint); border-color: var(--faint); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.actions .foot-note {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mute);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: var(--accent-tint);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.flash.err { border-left-color: var(--alarm); background: #f4e8e4; color: var(--alarm); }
|
||||||
|
|
||||||
|
/* ---------- runs list -------------------------------------------------- */
|
||||||
|
|
||||||
|
.run-count {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runs {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.runs li.run {
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding: 0.85rem 0 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5.5rem 1fr;
|
||||||
|
column-gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.runs li.run.just-submitted {
|
||||||
|
background: linear-gradient(to right, var(--accent-tint), transparent 60%);
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
margin-left: -0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run .stamp {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mute);
|
||||||
|
line-height: 1.3;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.run .stamp .id {
|
||||||
|
display: block;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run .body { min-width: 0; }
|
||||||
|
|
||||||
|
.run .line1 {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.run .badge {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 1px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.badge.running { color: var(--accent); background: var(--accent-tint); }
|
||||||
|
.badge.scheduled,
|
||||||
|
.badge.pending { color: var(--mute); }
|
||||||
|
.badge.completed { color: var(--good); background: #edf1ed; }
|
||||||
|
.badge.failed,
|
||||||
|
.badge.crashed,
|
||||||
|
.badge.cancelled { color: var(--alarm); background: #f4e8e4; }
|
||||||
|
|
||||||
|
.run .recipe {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.run .recipe .embedder { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--accent); }
|
||||||
|
.run .recipe .generator { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--mute); }
|
||||||
|
|
||||||
|
.run .paramline {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mute);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 1.1rem;
|
||||||
|
row-gap: 2px;
|
||||||
|
}
|
||||||
|
.run .paramline .k { color: var(--faint); }
|
||||||
|
|
||||||
|
.run .outputs {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 1.2rem;
|
||||||
|
row-gap: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
.run .outputs .tag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.run .outputs a[aria-disabled="true"] {
|
||||||
|
color: var(--faint);
|
||||||
|
font-style: italic;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.run .outputs a[aria-disabled="true"]::after {
|
||||||
|
content: " — waiting";
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* empty runs */
|
||||||
|
.empty {
|
||||||
|
padding: 1.4rem 0;
|
||||||
|
color: var(--mute);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer */
|
||||||
|
.colophon {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2.2rem 2rem;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
letter-spacing: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.colophon span.k { color: var(--mute); }
|
||||||
|
|
||||||
|
/* subtle htmx indicator */
|
||||||
|
.htmx-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
color: var(--mute);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.htmx-request .htmx-indicator { opacity: 1; }
|
||||||
|
.htmx-request.htmx-indicator { opacity: 1; }
|
||||||
89
app/web1/templates/_reducer_form.html
Normal file
89
app/web1/templates/_reducer_form.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{#
|
||||||
|
Partial: the parameter rows for one reducer.
|
||||||
|
Expects: reducer_key (str) OR default_reducer (on first render)
|
||||||
|
spec (dict) OR default_spec
|
||||||
|
#}
|
||||||
|
{% set rk = reducer_key if reducer_key is defined else default_reducer %}
|
||||||
|
{% set s = spec if spec is defined else default_spec %}
|
||||||
|
|
||||||
|
{% if not s %}
|
||||||
|
<p class="flash err">No reducers available — no supported package importable.</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
{% for name, kind, default, choices, help in s.key %}
|
||||||
|
{% set locked = (name in ('n_components', 'n_dims')) %}
|
||||||
|
<label for="embed__{{ name }}">
|
||||||
|
{{ name }}
|
||||||
|
{% if help %}<span class="hint">{{ help }}</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if kind == 'bool' %}
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
{% if default %}checked{% endif %} />
|
||||||
|
</span>
|
||||||
|
{% elif choices %}
|
||||||
|
<select id="embed__{{ name }}" name="embed__{{ name }}">
|
||||||
|
{% for c in choices %}
|
||||||
|
<option value="{{ c }}" {% if c == default %}selected{% endif %}>{{ c if c != "" else "(none)" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elif kind == 'int' %}
|
||||||
|
<input type="number" step="1" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" {% if locked %}readonly{% endif %} />
|
||||||
|
{% elif kind == 'float' %}
|
||||||
|
<input type="number" step="any" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" />
|
||||||
|
{% elif kind == 'int_or_null' %}
|
||||||
|
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" placeholder="int or empty" />
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if s.advanced %}
|
||||||
|
<details class="advanced">
|
||||||
|
<summary>show advanced</summary>
|
||||||
|
<div class="advanced-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{% for name, kind, default, choices, help in s.advanced %}
|
||||||
|
<label for="embed__{{ name }}">
|
||||||
|
{{ name }}
|
||||||
|
{% if help %}<span class="hint">{{ help }}</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if kind == 'bool' %}
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
{% if default %}checked{% endif %} />
|
||||||
|
</span>
|
||||||
|
{% elif choices %}
|
||||||
|
<select id="embed__{{ name }}" name="embed__{{ name }}">
|
||||||
|
{% for c in choices %}
|
||||||
|
<option value="{{ c }}" {% if c == default %}selected{% endif %}>{{ c if c != "" else "(none)" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elif kind == 'int' %}
|
||||||
|
<input type="number" step="1" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" />
|
||||||
|
{% elif kind == 'float' %}
|
||||||
|
<input type="number" step="any" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" />
|
||||||
|
{% elif kind == 'int_or_null' %}
|
||||||
|
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" placeholder="int or empty" />
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
|
||||||
|
value="{{ default }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
68
app/web1/templates/_runs.html
Normal file
68
app/web1/templates/_runs.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{#
|
||||||
|
Partial: runs list (right column).
|
||||||
|
Expects: runs (list of normalised view dicts), optional just_submitted (id)
|
||||||
|
#}
|
||||||
|
{% if not runs %}
|
||||||
|
<div class="empty">No runs yet. Dispatch one from the form on the left.</div>
|
||||||
|
{% else %}
|
||||||
|
<ul class="runs">
|
||||||
|
{% for r in runs %}
|
||||||
|
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}">
|
||||||
|
<div class="stamp">
|
||||||
|
{% if r.start %}{{ r.start[:10] }}<br/>{{ r.start[11:19] }}{% else %} {% endif %}
|
||||||
|
<span class="id">#{{ r.short_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="line1">
|
||||||
|
<span class="badge {{ r.state_type|lower }}">{{ r.state_name }}</span>
|
||||||
|
<span class="recipe">
|
||||||
|
<span class="embedder">{{ r.embedder_short or "?" }}</span>
|
||||||
|
<em>on</em>
|
||||||
|
<span class="generator">{{ r.generator_short or "?" }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if r.params %}
|
||||||
|
<div class="paramline">
|
||||||
|
<span><span class="k">N</span> {{ r.params.get('num_points', '?') }}</span>
|
||||||
|
<span><span class="k">S</span> {{ r.params.get('num_snapshots', '?') }}</span>
|
||||||
|
<span><span class="k">J</span> {{ r.params.get('jitter_scale', '?') }}</span>
|
||||||
|
<span><span class="k">s</span> {{ r.params.get('seed', '?') }}</span>
|
||||||
|
{% set ea = r.params.get('embed_args') or {} %}
|
||||||
|
{% if ea %}
|
||||||
|
{% for k, v in ea.items() %}
|
||||||
|
{% if k not in ('n_components','n_dims','random_state') %}
|
||||||
|
<span><span class="k">{{ k }}</span> {{ v }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="outputs">
|
||||||
|
<span class="tag">fig</span>
|
||||||
|
{% if r.ref_file %}
|
||||||
|
{% if r.ref_exists %}
|
||||||
|
<a href="/figs/{{ r.ref_file }}" target="_blank" rel="noopener">reference</a>
|
||||||
|
{% else %}
|
||||||
|
<a aria-disabled="true">reference</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faint);font-style:italic">reference: n/a</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if r.emb_file %}
|
||||||
|
{% if r.emb_exists %}
|
||||||
|
<a href="/figs/{{ r.emb_file }}" target="_blank" rel="noopener">embedding</a>
|
||||||
|
{% else %}
|
||||||
|
<a aria-disabled="true">embedding</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faint);font-style:italic">embedding: n/a</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
153
app/web1/templates/index.html
Normal file
153
app/web1/templates/index.html
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>embedding notebook — web1</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">embedding notebook <em>— drift & projection</em></h1>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="dot {% if not deployment_id %}bad{% endif %}"></span>
|
||||||
|
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}<br/>
|
||||||
|
<span style="color:var(--faint)">{{ prefect_api }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- ==================== LEFT: parameter notebook ==================== -->
|
||||||
|
<section class="col-left">
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="run-form"
|
||||||
|
hx-post="/submit"
|
||||||
|
hx-target="#runs-slot"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#busy"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- §1 reducer -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">
|
||||||
|
<span>§ 1 reducer</span><span class="ordinal">method</span>
|
||||||
|
</div>
|
||||||
|
<p class="lead">Dimensionality reduction applied to each snapshot. Only reducers whose Python package is importable are shown.</p>
|
||||||
|
|
||||||
|
<ul class="reducer-list">
|
||||||
|
{% for r in reducers %}
|
||||||
|
<li>
|
||||||
|
<input type="radio" name="reducer" id="red-{{ loop.index }}" value="{{ r.key }}"
|
||||||
|
{% if r.key == default_reducer %}checked{% endif %}
|
||||||
|
hx-get="/reducer-form?name={{ r.key }}"
|
||||||
|
hx-target="#reducer-params"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="change" />
|
||||||
|
<label for="red-{{ loop.index }}">
|
||||||
|
<span class="mark">{{ "%02d"|format(loop.index) }}</span>
|
||||||
|
<span>
|
||||||
|
<span class="name">{{ r.label }}</span>
|
||||||
|
<span class="blurb">{{ r.blurb }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="pkg">{{ r.key }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- §2 reducer params -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">
|
||||||
|
<span>§ 2 parameters</span><span class="ordinal">kwargs</span>
|
||||||
|
</div>
|
||||||
|
<div id="reducer-params">
|
||||||
|
{% include "_reducer_form.html" with context %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- §3 data -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">
|
||||||
|
<span>§ 3 data & drift</span><span class="ordinal">sampling</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label for="generator_path">
|
||||||
|
generator
|
||||||
|
<span class="hint">data-generating surface</span>
|
||||||
|
</label>
|
||||||
|
<select name="generator_path" id="generator_path">
|
||||||
|
{% for path, short in generators %}
|
||||||
|
<option value="{{ path }}" {% if path == 'sklearn.datasets.make_s_curve' %}selected{% endif %}>{{ short }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="num_points">n<sub>points</sub></label>
|
||||||
|
<input type="number" id="num_points" name="num_points" value="5000" min="100" step="100" />
|
||||||
|
|
||||||
|
<label for="num_snapshots">n<sub>snapshots</sub></label>
|
||||||
|
<input type="number" id="num_snapshots" name="num_snapshots" value="48" min="2" step="1" />
|
||||||
|
|
||||||
|
<label for="jitter_scale">
|
||||||
|
jitter scale
|
||||||
|
<span class="hint">std of per-step Gaussian drift</span>
|
||||||
|
</label>
|
||||||
|
<input type="number" id="jitter_scale" name="jitter_scale" value="0.01" step="0.001" min="0" />
|
||||||
|
|
||||||
|
<label for="seed">
|
||||||
|
jitter seed
|
||||||
|
<span class="hint">seeds only the drift simulation — the embedder's seed is in §2 (advanced).</span>
|
||||||
|
</label>
|
||||||
|
<input type="number" id="seed" name="seed" value="42" step="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="submit">submit run</button>
|
||||||
|
<span id="busy" class="htmx-indicator">dispatching…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== RIGHT: runs log ============================== -->
|
||||||
|
<section class="col-right">
|
||||||
|
|
||||||
|
<div class="section-label">
|
||||||
|
<span>§ 4 recent runs</span>
|
||||||
|
<span class="run-count">
|
||||||
|
<span id="runs-count">{{ runs|length }}</span> / 10 · refresh 3s
|
||||||
|
<span id="poll-ind" class="htmx-indicator" style="margin-left:6px">●</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="runs-slot"
|
||||||
|
hx-get="/runs"
|
||||||
|
hx-trigger="load delay:3s, every 3s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#poll-ind"
|
||||||
|
>
|
||||||
|
{% include "_runs.html" with context %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="colophon">
|
||||||
|
<span><span class="k">web1</span> · scientific instrument · port 8001</span>
|
||||||
|
<span>fastapi · htmx · no build step</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user