|
|
| """Open Discovery Challenge - submission intake and leaderboard.
|
|
|
| Deliberately thin. Nothing here computes a score.
|
|
|
| Scoring needs docking, docking needs a GPU, and a docking call takes minutes: the tunnel
|
| in front of this service cuts a request at ~125 s, and boltz spawns grandchildren that
|
| hold the stdout pipe open so a subprocess call never returns and wedges the whole worker.
|
| That combination already took the PharmaOS API down once. So submissions land in a ledger
|
| here, a worker on the GPU box picks them up, and results come back the same way. One
|
| entrant can never block the service.
|
|
|
| Endpoints
|
| GET / leaderboard page
|
| POST /api/submit accept a structure, run the gates, queue it
|
| GET /api/leaderboard ranked table, structures masked
|
| GET /api/queue how much work is outstanding
|
|
|
| The ledger is a private dataset, not a file in this container: a Space's filesystem does
|
| not survive a restart, and losing every submission to a rebuild is not acceptable when a
|
| prize depends on them. The worker reads that same dataset directly, so nothing here hands
|
| out work or accepts scores.
|
| """
|
| import base64
|
| import hashlib
|
| import hmac
|
| import json
|
| import os
|
| import secrets
|
| import time
|
| import urllib.parse
|
| import urllib.request
|
| import uuid
|
|
|
| from fastapi import FastAPI, HTTPException, Request
|
| from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
| from pydantic import BaseModel
|
|
|
| import gates
|
| import store
|
|
|
| HERE = os.path.dirname(os.path.abspath(__file__))
|
| ANCHORS = os.path.join(HERE, "data", "anchor_scores.json")
|
| CACHE = store.Cached(ttl=int(os.environ.get("ODC_CACHE_TTL", "20")))
|
|
|
| SALT = os.environ.get("ODC_SALT", "odc-season1")
|
|
|
|
|
|
|
|
|
| OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
| OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| COOKIE = "odc_session"
|
|
|
|
|
|
|
| IN_FRAME = bool(SPACE_HOST)
|
| COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
|
| else {"samesite": "lax", "secure": False})
|
| SESSION_KEY = os.environ.get("ODC_SESSION_KEY") or secrets.token_hex(16)
|
|
|
| SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria",
|
| "target": "PfDHODH", "counter_target": "human DHODH",
|
|
|
| "weights": {"activity": 30, "binding": 20, "selectivity": 20,
|
| "admet": 15, "novelty": 10, "synthesis": 5},
|
|
|
| "closes": "2026-09-30", "prize_usd": 1000}
|
|
|
| app = FastAPI(title="Open Discovery Challenge")
|
|
|
|
|
| class Submission(BaseModel):
|
| structure: str
|
| display_name: str
|
| model_name: str = ""
|
| rationale: str = ""
|
|
|
|
|
|
|
| visibility: str = "private"
|
|
|
|
|
| def _submissions():
|
| """Every accepted entry. Ids come from the tree; the rollup carries the scores, so a
|
| page load is two requests rather than one per entry."""
|
| return CACHE.get("ids", lambda: store.listdir("submissions"))
|
|
|
|
|
| def _board():
|
| return CACHE.get("board", lambda: store.read("leaderboard.json", default={}) or {})
|
|
|
|
|
| def public_id(inchikey):
|
| """A stable public handle that cannot be walked back to the structure.
|
|
|
| The skeleton block of an InChIKey is a hash of connectivity, so it is safe to show
|
| and still lets anyone check two entries are the same compound. The full key and the
|
| SMILES stay in the ledger."""
|
| h = hashlib.sha256((SALT + inchikey).encode()).hexdigest()[:6].upper()
|
| return "ODC-%s" % h
|
|
|
|
|
| def mask(rec):
|
| """What the public table is allowed to see: enough to verify and compare, never
|
| enough to reconstruct. Entrants keep their chemistry until they choose otherwise."""
|
| pub = rec.get("visibility") == "public"
|
| return {
|
| "candidate_id": rec.get("candidate_id"),
|
| "skeleton": (rec.get("inchikey") or "")[:14],
|
|
|
| "visibility": rec.get("visibility", "private"),
|
| "smiles": rec.get("smiles") if pub else None,
|
| "display_name": rec.get("display_name"),
|
| "hf_user": rec.get("hf_user"),
|
| "model_name": rec.get("model_name"),
|
| "mw_band": rec.get("mw_band"),
|
| "status": rec.get("status"),
|
| "total": rec.get("total"),
|
| "axes": rec.get("axes_points"),
|
| "tier": rec.get("tier", 1),
|
| "relegate_reason": rec.get("relegate_reason") or [],
|
| "submitted_at": rec.get("submitted_at"),
|
| }
|
|
|
|
|
| def band(x, step=50):
|
| if x is None:
|
| return None
|
| lo = int(x // step) * step
|
| return "%d-%d" % (lo, lo + step)
|
|
|
|
|
| def _sign(payload):
|
| raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
| sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
|
| return raw + "." + sig
|
|
|
|
|
| def _unsign(token):
|
| try:
|
| raw, sig = (token or "").rsplit(".", 1)
|
| except ValueError:
|
| return None
|
| good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
|
| if not hmac.compare_digest(sig, good):
|
| return None
|
| pad = "=" * (-len(raw) % 4)
|
| try:
|
| return json.loads(base64.urlsafe_b64decode(raw + pad))
|
| except Exception:
|
| return None
|
|
|
|
|
| def current_user(request: Request):
|
| return _unsign(request.cookies.get(COOKIE))
|
|
|
|
|
| def _redirect_uri(request: Request):
|
| if SPACE_HOST:
|
| return "https://%s/auth/callback" % SPACE_HOST
|
| return str(request.base_url).rstrip("/") + "/auth/callback"
|
|
|
|
|
| @app.get("/")
|
| def index():
|
| return FileResponse(os.path.join(HERE, "index.html"))
|
|
|
|
|
| @app.get("/api/me")
|
| def me(request: Request):
|
| u = current_user(request)
|
| return {"signed_in": bool(u), "user": u,
|
| "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
|
|
|
|
|
| @app.get("/login")
|
| def login(request: Request):
|
| if not (OAUTH_ID and OAUTH_SECRET):
|
| raise HTTPException(503, "OAuth가 설정되지 않았습니다 (README에 hf_oauth 필요)")
|
| state = secrets.token_urlsafe(16)
|
| q = urllib.parse.urlencode({
|
| "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
|
| "response_type": "code", "scope": "openid profile", "state": state})
|
| r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS.rstrip("/"), q))
|
|
|
|
|
| r.set_cookie("odc_state", state, max_age=600, httponly=True, **COOKIE_KW)
|
| return r
|
|
|
|
|
| @app.get("/auth/callback")
|
| def callback(request: Request, code: str = "", state: str = ""):
|
| saved = request.cookies.get("odc_state")
|
| if not code:
|
| raise HTTPException(400, "인증 코드가 없습니다 — 로그인을 다시 시도해 주세요")
|
| if not saved:
|
|
|
| raise HTTPException(400,
|
| "브라우저가 쿠키를 저장하지 못했습니다. 이 페이지를 "
|
| "새 탭에서 직접 열고(주소창의 .hf.space 주소) 다시 "
|
| "로그인해 주세요. 3자 쿠키 차단이 원인일 수 있습니다.")
|
| if state != saved:
|
| raise HTTPException(400, "로그인 상태값이 일치하지 않습니다 — 다시 시도해 주세요")
|
| body = urllib.parse.urlencode({
|
| "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
|
| "grant_type": "authorization_code", "code": code,
|
| "redirect_uri": _redirect_uri(request)}).encode()
|
| tok_req = urllib.request.Request(
|
| OAUTH_ISS.rstrip("/") + "/oauth/token", data=body,
|
| headers={"Content-Type": "application/x-www-form-urlencoded"})
|
| with urllib.request.urlopen(tok_req, timeout=20) as r:
|
| tok = json.loads(r.read().decode())
|
| ui_req = urllib.request.Request(
|
| OAUTH_ISS.rstrip("/") + "/oauth/userinfo",
|
| headers={"Authorization": "Bearer " + tok["access_token"]})
|
| with urllib.request.urlopen(ui_req, timeout=20) as r:
|
| ui = json.loads(r.read().decode())
|
| user = {"name": ui.get("preferred_username") or ui.get("name"),
|
| "picture": ui.get("picture"), "at": int(time.time())}
|
| resp = RedirectResponse("/")
|
| resp.set_cookie(COOKIE, _sign(user), max_age=60 * 60 * 24 * 14,
|
| httponly=True, **COOKIE_KW)
|
| resp.delete_cookie("odc_state")
|
| return resp
|
|
|
|
|
| @app.get("/logout")
|
| def logout():
|
| r = RedirectResponse("/")
|
| r.delete_cookie(COOKIE)
|
| return r
|
|
|
|
|
| @app.get("/api/season")
|
| def season():
|
| return SEASON
|
|
|
|
|
| @app.post("/api/submit")
|
| def submit(s: Submission, request: Request):
|
| user = current_user(request)
|
| if (OAUTH_ID and OAUTH_SECRET) and not user:
|
| raise HTTPException(401, "Hugging Face 로그인이 필요합니다")
|
| if not s.display_name.strip():
|
| raise HTTPException(400, "표시 ID를 입력하세요")
|
|
|
| v = gates.check(s.structure, covalent_rule=False)
|
| if not v["admitted"]:
|
| return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422)
|
|
|
|
|
|
|
| if store.read("submissions/%s.json" % public_id(v["inchikey"])) is not None:
|
| return JSONResponse(
|
| {"accepted": False,
|
| "reasons": ["이미 제출된 구조입니다 (%s)" % public_id(v["inchikey"])]},
|
| status_code=409)
|
|
|
| rec = {
|
|
|
|
|
| "id": public_id(v["inchikey"]),
|
| "candidate_id": public_id(v["inchikey"]),
|
| "smiles": v["smiles"], "inchikey": v["inchikey"],
|
| "mw": v["mw"], "mw_band": band(v["mw"]),
|
|
|
|
|
| "hf_user": (user or {}).get("name"),
|
| "visibility": "public" if s.visibility == "public" else "private",
|
| "display_name": s.display_name.strip()[:60],
|
| "model_name": s.model_name.strip()[:80],
|
| "rationale": s.rationale.strip()[:2000],
|
| "status": "queued", "submitted_at": int(time.time()),
|
| }
|
| store.write("submissions/%s.json" % rec["id"], rec,
|
| summary="entry %s" % rec["candidate_id"])
|
| CACHE.drop()
|
| scored = set((_board().get("entries") or {}).keys())
|
| ahead = len([i for i in _submissions() if i not in scored])
|
| return {"accepted": True, "candidate_id": rec["candidate_id"],
|
| "queue_position": ahead,
|
| "note": "채점은 GPU 작업으로 처리되며 완료까지 몇 분 걸립니다."}
|
|
|
|
|
| @app.get("/api/leaderboard")
|
| def leaderboard():
|
| rows = list((_board().get("entries") or {}).values())
|
| anchors = []
|
| if os.path.exists(ANCHORS):
|
| for a in json.load(open(ANCHORS, encoding="utf-8")):
|
| if not a.get("admitted"):
|
| continue
|
| anchors.append({"candidate_id": a["label"], "is_anchor": True,
|
| "display_name": "기준물질", "model_name": "",
|
| "total": a["total"], "tier": a.get("tier", 1),
|
| "axes": {k: v["points"] for k, v in a["axes"].items()},
|
|
|
|
|
| "detail": {k: v["detail"] for k, v in a["axes"].items()},
|
| "note": a.get("note", "")})
|
| merged = rows + anchors
|
| merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") or -1)))
|
| n = 0
|
| for e in merged:
|
| if e.get("is_anchor") or e.get("tier", 1) != 1:
|
| e["rank"] = None
|
| else:
|
| n += 1
|
| e["rank"] = n
|
| return {"season": SEASON, "entries": merged,
|
| "counts": {"scored": len(rows), "anchors": len(anchors)}}
|
|
|
|
|
| @app.get("/api/models")
|
| def models():
|
| """Per-model standings: which model produced the best candidate, and which gets used.
|
|
|
| Deliberately two separate numbers. Popularity is not quality - a model everyone
|
| reaches for will rack up entries regardless of whether any of them score, and a model
|
| used three times could hold the top result. Reporting only a mean would hide both:
|
| one lucky hit drowns in a hundred weak entries, and a model with two good tries looks
|
| better than one with fifty. So the chart carries best, mean and count side by side.
|
|
|
| Reference compounds are excluded - they were not produced by an entrant's model.
|
| """
|
| agg = {}
|
| for r in (_board().get("entries") or {}).values():
|
| if r.get("total") is None:
|
| continue
|
| name = (r.get("model_name") or "").strip() or "미기재 / unspecified"
|
| a = agg.setdefault(name, {"model": name, "n": 0, "best": None, "sum": 0.0,
|
| "entrants": set(), "top_candidate": None})
|
| a["n"] += 1
|
| a["sum"] += r["total"]
|
| if a["best"] is None or r["total"] > a["best"]:
|
| a["best"] = r["total"]
|
| a["top_candidate"] = r.get("candidate_id")
|
| who = r.get("hf_user") or r.get("display_name")
|
| if who:
|
| a["entrants"].add(who)
|
| out = []
|
| for a in agg.values():
|
| out.append({"model": a["model"], "submissions": a["n"],
|
| "best": round(a["best"], 1) if a["best"] is not None else None,
|
| "mean": round(a["sum"] / a["n"], 1) if a["n"] else None,
|
| "entrants": len(a["entrants"]),
|
| "top_candidate": a["top_candidate"]})
|
| out.sort(key=lambda x: (-(x["best"] or 0), -x["submissions"]))
|
| return {"models": out,
|
| "totals": {"models": len(out), "submissions": sum(x["submissions"] for x in out)}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @app.get("/api/mol3d")
|
| def mol3d(smiles: str = ""):
|
| """A 3D conformer for the viewer, as an MDL mol block.
|
|
|
| Published structures only. The lookup is against the rollup rather than trusting the
|
| caller: the page only ever has public strings, but an endpoint that embeds whatever
|
| it is given would answer "is this the private entry?" for anyone willing to guess.
|
| """
|
| from rdkit import Chem
|
| from rdkit.Chem import AllChem, rdMolDescriptors
|
|
|
| s = (smiles or "").strip()
|
| if not s:
|
| raise HTTPException(400, "no structure given")
|
| published = {e.get("smiles") for e in (_board().get("entries") or {}).values()
|
| if e.get("visibility") == "public" and e.get("smiles")}
|
| if s not in published:
|
| raise HTTPException(404, "not a published structure")
|
|
|
| m = Chem.MolFromSmiles(s)
|
| if m is None:
|
| raise HTTPException(400, "unparseable structure")
|
| mh = Chem.AddHs(m)
|
|
|
|
|
| if AllChem.EmbedMolecule(mh, randomSeed=1) != 0:
|
| AllChem.Compute2DCoords(mh)
|
| else:
|
| try:
|
| AllChem.MMFFOptimizeMolecule(mh, maxIters=400)
|
| except Exception:
|
| pass
|
| return {"mol": Chem.MolToMolBlock(mh),
|
| "formula": rdMolDescriptors.CalcMolFormula(m),
|
| "mw": round(rdMolDescriptors.CalcExactMolWt(m), 2),
|
| "atoms": m.GetNumAtoms(), "atoms_h": mh.GetNumAtoms()}
|
|
|
|
|
| @app.get("/api/queue")
|
| def queue():
|
| """How much work is outstanding.
|
|
|
| This was declared directly after the worker endpoints, so removing that block took it
|
| along too. The page never calls it, so nothing looked broken - it surfaced only by
|
| exercising every route after a factory rebuild.
|
| """
|
| ids = _submissions()
|
| scored = set((_board().get("entries") or {}).keys())
|
| return {"queued": len([i for i in ids if i not in scored]),
|
| "scored": len(scored), "total": len(ids)}
|
|
|