VIDRAFT Claude Opus 5 commited on
Commit
b2e7518
Β·
0 Parent(s):

Open Discovery Challenge #1 Malaria - submission intake and leaderboard

Browse files

Scoring runs on a separate GPU worker, never in this service: docking takes minutes,
the tunnel cuts at ~125s, and an in-process boltz call already wedged an API once.
Submissions queue here and results are posted back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (8) hide show
  1. .gitignore +2 -0
  2. Dockerfile +12 -0
  3. README.md +18 -0
  4. app.py +218 -0
  5. data/anchor_scores.json +382 -0
  6. gates.py +138 -0
  7. index.html +167 -0
  8. requirements.txt +4 -0
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ data/ledger.jsonl
2
+ __pycache__/
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Spaces runs the container as a non-root user; write only under paths we own.
2
+ FROM python:3.11-slim
3
+ RUN useradd -m -u 1000 user
4
+ WORKDIR /app
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+ COPY . .
8
+ RUN mkdir -p /app/data && chown -R user:user /app
9
+ USER user
10
+ ENV ODC_LEDGER=/app/data/ledger.jsonl
11
+ EXPOSE 7860
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Open Discovery Challenge
3
+ emoji: 🧬
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Open Discovery Challenge #1 β€” Malaria
12
+
13
+ AIκ°€ μ œμ•ˆν•œ μ €λΆ„μž 후보λ₯Ό 곡개된 κΈ°μ€€μœΌλ‘œ μžλ™ μ±„μ ν•˜λŠ” κ°œλ°©ν˜• μ‹ μ•½ 발꡴ λŒ€νšŒ.
14
+
15
+ - **ν‘œμ ** PfDHODH (*Plasmodium falciparum* dihydroorotate dehydrogenase)
16
+ - **μ—­ν‘œμ ** human DHODH β€” 기생좩 νš¨μ†Œλ§Œ 막고 μ‚¬λžŒ 것은 ν”Όν•΄μ•Ό 함
17
+
18
+ μ μˆ˜λŠ” **계산 기반 후보 평가**이며, μ‹€μ œ 효λŠ₯Β·μ•ˆμ „μ„±μ΄λ‚˜ μŠΉμΈμ•½κ³Όμ˜ μš°μ—΄μ„ λœ»ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
app.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Open Discovery Challenge - submission intake and leaderboard.
3
+
4
+ Deliberately thin. Nothing here computes a score.
5
+
6
+ Scoring needs docking, docking needs a GPU, and a docking call takes minutes: the tunnel
7
+ in front of this service cuts a request at ~125 s, and boltz spawns grandchildren that
8
+ hold the stdout pipe open so a subprocess call never returns and wedges the whole worker.
9
+ That combination already took the PharmaOS API down once. So submissions land in a ledger
10
+ here, a worker on the GPU box picks them up, and results come back the same way. One
11
+ entrant can never block the service.
12
+
13
+ Endpoints
14
+ GET / leaderboard page
15
+ POST /api/submit accept a structure, run the gates, queue it
16
+ GET /api/leaderboard ranked table, structures masked
17
+ GET /api/queue how much work is outstanding
18
+ GET /api/pending worker: claim unscored entries
19
+ POST /api/result worker: post a score back
20
+ """
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import time
25
+ import uuid
26
+
27
+ from fastapi import FastAPI, HTTPException, Request
28
+ from fastapi.responses import FileResponse, JSONResponse
29
+ from pydantic import BaseModel
30
+
31
+ import gates
32
+
33
+ HERE = os.path.dirname(os.path.abspath(__file__))
34
+ LEDGER = os.environ.get("ODC_LEDGER", os.path.join(HERE, "data", "ledger.jsonl"))
35
+ ANCHORS = os.path.join(HERE, "data", "anchor_scores.json")
36
+ # Only the worker may post scores. Without this anyone could write their own.
37
+ WORKER_KEY = os.environ.get("ODC_WORKER_KEY", "")
38
+ # Masked identifiers must not be reversible from the public table.
39
+ SALT = os.environ.get("ODC_SALT", "odc-season1")
40
+
41
+ SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria",
42
+ "target": "PfDHODH", "counter_target": "human DHODH",
43
+ "weights": {"μ•½νš¨": 30, "κ²°ν•©": 20, "선택성": 20, "ADMET": 15, "μ‹ κ·œμ„±": 10, "ν•©μ„±": 5}}
44
+
45
+ app = FastAPI(title="Open Discovery Challenge")
46
+ os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
47
+
48
+
49
+ class Submission(BaseModel):
50
+ structure: str # SMILES or InChI
51
+ display_name: str # name, affiliation or handle - entrant's choice
52
+ model_name: str = "" # which model proposed it; free text, blank allowed
53
+ rationale: str = "" # optional design note
54
+
55
+
56
+ def _read():
57
+ if not os.path.exists(LEDGER):
58
+ return []
59
+ out = []
60
+ with open(LEDGER, encoding="utf-8") as f:
61
+ for line in f:
62
+ line = line.strip()
63
+ if line:
64
+ out.append(json.loads(line))
65
+ return out
66
+
67
+
68
+ def _append(rec):
69
+ with open(LEDGER, "a", encoding="utf-8") as f:
70
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
71
+
72
+
73
+ def _latest():
74
+ """Last write per submission id wins, so a worker can post a score by appending."""
75
+ by_id = {}
76
+ for r in _read():
77
+ by_id[r["id"]] = {**by_id.get(r["id"], {}), **r}
78
+ return list(by_id.values())
79
+
80
+
81
+ def public_id(inchikey):
82
+ """A stable public handle that cannot be walked back to the structure.
83
+
84
+ The skeleton block of an InChIKey is a hash of connectivity, so it is safe to show
85
+ and still lets anyone check two entries are the same compound. The full key and the
86
+ SMILES stay in the ledger."""
87
+ h = hashlib.sha256((SALT + inchikey).encode()).hexdigest()[:6].upper()
88
+ return "ODC-%s" % h
89
+
90
+
91
+ def mask(rec):
92
+ """What the public table is allowed to see: enough to verify and compare, never
93
+ enough to reconstruct. Entrants keep their chemistry until they choose otherwise."""
94
+ return {
95
+ "candidate_id": rec.get("candidate_id"),
96
+ "skeleton": (rec.get("inchikey") or "")[:14],
97
+ "display_name": rec.get("display_name"),
98
+ "model_name": rec.get("model_name"),
99
+ "mw_band": rec.get("mw_band"),
100
+ "status": rec.get("status"),
101
+ "total": rec.get("total"),
102
+ "axes": rec.get("axes_points"),
103
+ "tier": rec.get("tier", 1),
104
+ "relegate_reason": rec.get("relegate_reason") or [],
105
+ "submitted_at": rec.get("submitted_at"),
106
+ }
107
+
108
+
109
+ def band(x, step=50):
110
+ if x is None:
111
+ return None
112
+ lo = int(x // step) * step
113
+ return "%d-%d" % (lo, lo + step)
114
+
115
+
116
+ @app.get("/")
117
+ def index():
118
+ return FileResponse(os.path.join(HERE, "index.html"))
119
+
120
+
121
+ @app.get("/api/season")
122
+ def season():
123
+ return SEASON
124
+
125
+
126
+ @app.post("/api/submit")
127
+ def submit(s: Submission):
128
+ if not s.display_name.strip():
129
+ raise HTTPException(400, "ν‘œμ‹œ IDλ₯Ό μž…λ ₯ν•˜μ„Έμš”")
130
+ # Season 1's target is not a covalent-mechanism enzyme, so that rule is off here.
131
+ v = gates.check(s.structure, covalent_rule=False)
132
+ if not v["admitted"]:
133
+ return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422)
134
+
135
+ existing = {r.get("inchikey") for r in _latest()}
136
+ if v["inchikey"] in existing:
137
+ return JSONResponse(
138
+ {"accepted": False,
139
+ "reasons": ["이미 제좜된 κ΅¬μ‘°μž…λ‹ˆλ‹€ (%s)" % public_id(v["inchikey"])]},
140
+ status_code=409)
141
+
142
+ rec = {
143
+ "id": uuid.uuid4().hex,
144
+ "candidate_id": public_id(v["inchikey"]),
145
+ "smiles": v["smiles"], "inchikey": v["inchikey"],
146
+ "mw": v["mw"], "mw_band": band(v["mw"]),
147
+ "display_name": s.display_name.strip()[:60],
148
+ "model_name": s.model_name.strip()[:80],
149
+ "rationale": s.rationale.strip()[:2000],
150
+ "status": "queued", "submitted_at": int(time.time()),
151
+ }
152
+ _append(rec)
153
+ ahead = sum(1 for r in _latest() if r.get("status") == "queued")
154
+ return {"accepted": True, "candidate_id": rec["candidate_id"],
155
+ "queue_position": ahead,
156
+ "note": "채점은 GPU μž‘μ—…μœΌλ‘œ 처리되며 μ™„λ£ŒκΉŒμ§€ λͺ‡ λΆ„ κ±Έλ¦½λ‹ˆλ‹€."}
157
+
158
+
159
+ @app.get("/api/leaderboard")
160
+ def leaderboard():
161
+ rows = [mask(r) for r in _latest() if r.get("status") == "scored"]
162
+ anchors = []
163
+ if os.path.exists(ANCHORS):
164
+ for a in json.load(open(ANCHORS, encoding="utf-8")):
165
+ if not a.get("admitted"):
166
+ continue
167
+ anchors.append({"candidate_id": a["label"], "is_anchor": True,
168
+ "display_name": "κΈ°μ€€λ¬Όμ§ˆ", "model_name": "",
169
+ "total": a["total"], "tier": a.get("tier", 1),
170
+ "axes": {k: v["points"] for k, v in a["axes"].items()},
171
+ "note": a.get("note", "")})
172
+ merged = rows + anchors
173
+ merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") or -1)))
174
+ n = 0
175
+ for e in merged:
176
+ if e.get("is_anchor") or e.get("tier", 1) != 1:
177
+ e["rank"] = None # anchors mark the ladder; they do not climb it
178
+ else:
179
+ n += 1
180
+ e["rank"] = n
181
+ return {"season": SEASON, "entries": merged,
182
+ "counts": {"scored": len(rows), "anchors": len(anchors)}}
183
+
184
+
185
+ @app.get("/api/queue")
186
+ def queue():
187
+ rs = _latest()
188
+ return {"queued": sum(1 for r in rs if r.get("status") == "queued"),
189
+ "scoring": sum(1 for r in rs if r.get("status") == "scoring"),
190
+ "scored": sum(1 for r in rs if r.get("status") == "scored")}
191
+
192
+
193
+ def _auth(request: Request):
194
+ if not WORKER_KEY or request.headers.get("X-Worker-Key") != WORKER_KEY:
195
+ raise HTTPException(401, "worker key required")
196
+
197
+
198
+ @app.get("/api/pending")
199
+ def pending(request: Request, limit: int = 5):
200
+ _auth(request)
201
+ out = [r for r in _latest() if r.get("status") == "queued"][:limit]
202
+ for r in out:
203
+ _append({"id": r["id"], "status": "scoring"})
204
+ return {"items": [{"id": r["id"], "smiles": r["smiles"],
205
+ "candidate_id": r["candidate_id"]} for r in out]}
206
+
207
+
208
+ @app.post("/api/result")
209
+ async def result(request: Request):
210
+ _auth(request)
211
+ body = await request.json()
212
+ if "id" not in body:
213
+ raise HTTPException(400, "id required")
214
+ _append({"id": body["id"], "status": "scored",
215
+ "total": body.get("total"), "axes_points": body.get("axes_points"),
216
+ "tier": body.get("tier", 1), "relegate_reason": body.get("relegate_reason"),
217
+ "scored_at": int(time.time()), "run": body.get("run")})
218
+ return {"ok": True}
data/anchor_scores.json ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "admitted": true,
4
+ "gate": {
5
+ "admitted": true,
6
+ "reject": [],
7
+ "relegate": [],
8
+ "notes": [],
9
+ "smiles": "Cc1cc(Nc2ccc(S(F)(F)(F)(F)F)cc2)n2nc(C(C)(F)F)nc2n1",
10
+ "inchikey": "OIZSVTOIBNSVOS-UHFFFAOYSA-N",
11
+ "mw": 415.34,
12
+ "heavy_atoms": 27
13
+ },
14
+ "relegated": false,
15
+ "relegate_reason": [],
16
+ "axes": {
17
+ "activity": {
18
+ "points": 14.45,
19
+ "detail": {
20
+ "pAct_q10": 5.467,
21
+ "fraction": 0.482
22
+ }
23
+ },
24
+ "binding": {
25
+ "points": 15.12,
26
+ "detail": {
27
+ "pIC50": 7.27,
28
+ "LE": 0.369,
29
+ "potency_term": 0.756,
30
+ "efficiency_cap": 1.0
31
+ }
32
+ },
33
+ "selectivity": {
34
+ "points": 12.59,
35
+ "detail": {
36
+ "fold": 46.3,
37
+ "selectivity_term": 0.833,
38
+ "target_engagement": 0.756
39
+ }
40
+ },
41
+ "admet": {
42
+ "points": 5.89,
43
+ "detail": {
44
+ "geometric_mean": 0.519,
45
+ "per_endpoint": {
46
+ "solubility-aqsoldb": 0.405,
47
+ "caco2-wang": 0.763,
48
+ "hia-hou": 1.0,
49
+ "bioavailability-ma": 1.0,
50
+ "pgp-broccatelli": 1.0,
51
+ "cyp2c9-veith": 0.349,
52
+ "cyp3a4-substrate-carbonmangels": 0.638,
53
+ "ld50-zhu": 0.66,
54
+ "herg": 0.116,
55
+ "dili": 0.05
56
+ }
57
+ }
58
+ },
59
+ "novelty": {
60
+ "points": 0.0,
61
+ "detail": {
62
+ "max_tanimoto": 1.0,
63
+ "novelty": 0.0
64
+ }
65
+ },
66
+ "synthesis": {
67
+ "points": 2.84,
68
+ "detail": {
69
+ "sa_score": 3.37
70
+ }
71
+ }
72
+ },
73
+ "total": 50.88,
74
+ "tier": 1,
75
+ "label": "DSM265",
76
+ "is_anchor": true,
77
+ "note": "μž„μƒν›„λ³΄ (μ–‘μ„± 액컀)",
78
+ "rank": null
79
+ },
80
+ {
81
+ "admitted": true,
82
+ "gate": {
83
+ "admitted": true,
84
+ "reject": [],
85
+ "relegate": [],
86
+ "notes": [],
87
+ "smiles": "Cc1c(-c2ccc(-c3ccccc3F)cc2)nc2ccc(F)cc2c1C(=O)O",
88
+ "inchikey": "PHEZJEYUWHETKO-UHFFFAOYSA-N",
89
+ "mw": 375.37,
90
+ "heavy_atoms": 28
91
+ },
92
+ "relegated": false,
93
+ "relegate_reason": [],
94
+ "axes": {
95
+ "activity": {
96
+ "points": 2.58,
97
+ "detail": {
98
+ "pAct_q10": 4.755,
99
+ "fraction": 0.086
100
+ }
101
+ },
102
+ "binding": {
103
+ "points": 0.0,
104
+ "detail": {
105
+ "reason": "도킹 μ—†μŒ"
106
+ }
107
+ },
108
+ "selectivity": {
109
+ "points": 0.0,
110
+ "detail": {
111
+ "reason": "쌍 μΈ‘μ • μ—†μŒ"
112
+ }
113
+ },
114
+ "admet": {
115
+ "points": 0.47,
116
+ "detail": {
117
+ "geometric_mean": 0.363,
118
+ "per_endpoint": {
119
+ "solubility-aqsoldb": 0.05,
120
+ "caco2-wang": 0.714,
121
+ "hia-hou": 1.0,
122
+ "bioavailability-ma": 1.0,
123
+ "pgp-broccatelli": 0.766,
124
+ "cyp2c9-veith": 0.696,
125
+ "cyp3a4-substrate-carbonmangels": 0.88,
126
+ "ld50-zhu": 0.271,
127
+ "herg": 0.115,
128
+ "dili": 0.05
129
+ }
130
+ }
131
+ },
132
+ "novelty": {
133
+ "points": 0.56,
134
+ "detail": {
135
+ "max_tanimoto": 0.348,
136
+ "novelty": 0.652
137
+ }
138
+ },
139
+ "synthesis": {
140
+ "points": 0.43,
141
+ "detail": {
142
+ "sa_score": 2.06
143
+ }
144
+ }
145
+ },
146
+ "total": 4.04,
147
+ "tier": 1,
148
+ "label": "Brequinar",
149
+ "is_anchor": false,
150
+ "note": "μ‚¬λžŒ DHODH μ–΅μ œμ œ (선택성 λ°˜λ‘€)",
151
+ "rank": 1
152
+ },
153
+ {
154
+ "admitted": true,
155
+ "gate": {
156
+ "admitted": true,
157
+ "reject": [],
158
+ "relegate": [],
159
+ "notes": [],
160
+ "smiles": "C/C(O)=C(\\C#N)C(=O)Nc1ccc(C(F)(F)F)cc1",
161
+ "inchikey": "UTNUDOFZCWSZMS-YFHOEESVSA-N",
162
+ "mw": 270.21,
163
+ "heavy_atoms": 19
164
+ },
165
+ "relegated": false,
166
+ "relegate_reason": [],
167
+ "axes": {
168
+ "activity": {
169
+ "points": 1.66,
170
+ "detail": {
171
+ "pAct_q10": 4.362,
172
+ "fraction": 0.055
173
+ }
174
+ },
175
+ "binding": {
176
+ "points": 0.0,
177
+ "detail": {
178
+ "reason": "도킹 μ—†μŒ"
179
+ }
180
+ },
181
+ "selectivity": {
182
+ "points": 0.0,
183
+ "detail": {
184
+ "reason": "쌍 μΈ‘μ • μ—†μŒ"
185
+ }
186
+ },
187
+ "admet": {
188
+ "points": 0.51,
189
+ "detail": {
190
+ "geometric_mean": 0.614,
191
+ "per_endpoint": {
192
+ "solubility-aqsoldb": 0.575,
193
+ "caco2-wang": 0.851,
194
+ "hia-hou": 1.0,
195
+ "bioavailability-ma": 1.0,
196
+ "pgp-broccatelli": 1.0,
197
+ "cyp2c9-veith": 0.568,
198
+ "cyp3a4-substrate-carbonmangels": 0.21,
199
+ "ld50-zhu": 0.459,
200
+ "herg": 1.0,
201
+ "dili": 0.11
202
+ }
203
+ }
204
+ },
205
+ "novelty": {
206
+ "points": 0.35,
207
+ "detail": {
208
+ "max_tanimoto": 0.357,
209
+ "novelty": 0.643
210
+ }
211
+ },
212
+ "synthesis": {
213
+ "points": 0.28,
214
+ "detail": {
215
+ "sa_score": 2.27
216
+ }
217
+ }
218
+ },
219
+ "total": 2.79,
220
+ "tier": 1,
221
+ "label": "Teriflunomide",
222
+ "is_anchor": false,
223
+ "note": "μ‚¬λžŒ DHODH μ–΅μ œμ œ (선택성 λ°˜λ‘€)",
224
+ "rank": 2
225
+ },
226
+ {
227
+ "admitted": true,
228
+ "gate": {
229
+ "admitted": true,
230
+ "reject": [],
231
+ "relegate": [],
232
+ "notes": [],
233
+ "smiles": "CC(C)Cc1ccc(C(C)C(=O)O)cc1",
234
+ "inchikey": "HEFNNWSXXWATRW-UHFFFAOYSA-N",
235
+ "mw": 206.28,
236
+ "heavy_atoms": 15
237
+ },
238
+ "relegated": false,
239
+ "relegate_reason": [],
240
+ "axes": {
241
+ "activity": {
242
+ "points": 1.04,
243
+ "detail": {
244
+ "pAct_q10": 4.174,
245
+ "fraction": 0.035
246
+ }
247
+ },
248
+ "binding": {
249
+ "points": 0.06,
250
+ "detail": {
251
+ "pIC50": 3.57,
252
+ "LE": 0.326,
253
+ "potency_term": 0.003,
254
+ "efficiency_cap": 1.0
255
+ }
256
+ },
257
+ "selectivity": {
258
+ "points": 0.0,
259
+ "detail": {
260
+ "fold": 0.3,
261
+ "selectivity_term": 0.0,
262
+ "target_engagement": 0.0
263
+ }
264
+ },
265
+ "admet": {
266
+ "points": 0.38,
267
+ "detail": {
268
+ "geometric_mean": 0.74,
269
+ "per_endpoint": {
270
+ "solubility-aqsoldb": 0.677,
271
+ "caco2-wang": 1.0,
272
+ "hia-hou": 1.0,
273
+ "bioavailability-ma": 1.0,
274
+ "pgp-broccatelli": 1.0,
275
+ "cyp2c9-veith": 1.0,
276
+ "cyp3a4-substrate-carbonmangels": 1.0,
277
+ "ld50-zhu": 0.212,
278
+ "herg": 1.0,
279
+ "dili": 0.528
280
+ }
281
+ }
282
+ },
283
+ "novelty": {
284
+ "points": 0.21,
285
+ "detail": {
286
+ "max_tanimoto": 0.386,
287
+ "novelty": 0.614
288
+ }
289
+ },
290
+ "synthesis": {
291
+ "points": 0.17,
292
+ "detail": {
293
+ "sa_score": 2.19
294
+ }
295
+ }
296
+ },
297
+ "total": 1.86,
298
+ "tier": 1,
299
+ "label": "Ibuprofen",
300
+ "is_anchor": true,
301
+ "note": "μŒμ„±λŒ€μ‘°",
302
+ "rank": null
303
+ },
304
+ {
305
+ "admitted": true,
306
+ "gate": {
307
+ "admitted": true,
308
+ "reject": [],
309
+ "relegate": [],
310
+ "notes": [],
311
+ "smiles": "Cn1c(=O)c2c(ncn2C)n(C)c1=O",
312
+ "inchikey": "RYYVLZVUVIJVGH-UHFFFAOYSA-N",
313
+ "mw": 194.19,
314
+ "heavy_atoms": 14
315
+ },
316
+ "relegated": false,
317
+ "relegate_reason": [],
318
+ "axes": {
319
+ "activity": {
320
+ "points": 1.1,
321
+ "detail": {
322
+ "pAct_q10": 4.197,
323
+ "fraction": 0.037
324
+ }
325
+ },
326
+ "binding": {
327
+ "points": 0.06,
328
+ "detail": {
329
+ "pIC50": 3.62,
330
+ "LE": 0.354,
331
+ "potency_term": 0.003,
332
+ "efficiency_cap": 1.0
333
+ }
334
+ },
335
+ "selectivity": {
336
+ "points": 0.0,
337
+ "detail": {
338
+ "fold": 1.3,
339
+ "selectivity_term": 0.058,
340
+ "target_engagement": 0.0
341
+ }
342
+ },
343
+ "admet": {
344
+ "points": 0.27,
345
+ "detail": {
346
+ "geometric_mean": 0.496,
347
+ "per_endpoint": {
348
+ "solubility-aqsoldb": 1.0,
349
+ "caco2-wang": 1.0,
350
+ "hia-hou": 1.0,
351
+ "bioavailability-ma": 1.0,
352
+ "pgp-broccatelli": 1.0,
353
+ "cyp2c9-veith": 1.0,
354
+ "cyp3a4-substrate-carbonmangels": 0.05,
355
+ "ld50-zhu": 0.096,
356
+ "herg": 0.1,
357
+ "dili": 0.58
358
+ }
359
+ }
360
+ },
361
+ "novelty": {
362
+ "points": 0.23,
363
+ "detail": {
364
+ "max_tanimoto": 0.367,
365
+ "novelty": 0.633
366
+ }
367
+ },
368
+ "synthesis": {
369
+ "points": 0.18,
370
+ "detail": {
371
+ "sa_score": 2.3
372
+ }
373
+ }
374
+ },
375
+ "total": 1.84,
376
+ "tier": 1,
377
+ "label": "Caffeine",
378
+ "is_anchor": true,
379
+ "note": "μŒμ„±λŒ€μ‘°",
380
+ "rank": null
381
+ }
382
+ ]
gates.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Season 1 admission gates.
3
+
4
+ Gates decide eligibility, never quality. Anything that reflects how good a molecule is
5
+ belongs in the continuous score; a gate only answers "is this a valid Season 1 entry".
6
+ Failing a gate does not put a submission at the bottom of the table - it means the entry
7
+ is returned, because a covalent warhead in a non-covalent season is not a bad candidate,
8
+ it is the wrong kind of candidate.
9
+
10
+ The one exception is ADMET, and it is deliberate: a candidate that fails a toxicity gate
11
+ is still scored in full and still shown, but sorts below every clean entry no matter how
12
+ high that score is. A molecule that binds beautifully and is mutagenic is not a lead.
13
+
14
+ Every threshold here was checked against a panel of approved drugs first. A gate that
15
+ rejects an approved drug is a broken gate, and two of the obvious candidates - hERG and
16
+ DILI - were thrown out for exactly that reason: our own ADMET model scores ensitrelvir
17
+ at hERG 0.978 and DILI 0.995, and caffeine at hERG 0.885. See data/anchors.json.
18
+ """
19
+ from rdkit import Chem, RDLogger
20
+ from rdkit.Chem import Descriptors
21
+ from rdkit.Chem.FilterCatalog import FilterCatalog, FilterCatalogParams
22
+
23
+ RDLogger.DisableLog("rdApp.*")
24
+
25
+ _p = FilterCatalogParams()
26
+ _p.AddCatalog(FilterCatalogParams.FilterCatalogs.PAINS)
27
+ _PAINS = FilterCatalog(_p)
28
+
29
+ MW_MAX = 550.0 # ensitrelvir is 531.9; a 500 cap would disqualify the reference drug
30
+ HEAVY_MAX = 45
31
+ AMES_MAX = 0.7 # panel range was 0.024-0.302, so this leaves real headroom
32
+ LOGS_MIN = -7.0 # panel low was -5.42
33
+
34
+ # Electrophiles that form a covalent bond with the catalytic residue. Season 1 excludes
35
+ # them: they are a different medicinal-chemistry problem, and a docking-based score
36
+ # rewards raw reactivity in ways that are easy to game.
37
+ WARHEADS = [
38
+ # A nitrile on its own is not a warhead. Teriflunomide is an approved, non-covalent
39
+ # DHODH inhibitor and a bare [CX2]#[NX1] pattern rejected it - the same mistake as
40
+ # gating on hERG, caught the same way, by running an approved drug through first.
41
+ # What actually reacts with a catalytic cysteine is the peptidyl nitrile: the nitrile
42
+ # carbon sitting on an sp3 centre that carries the amide nitrogen, as in nirmatrelvir.
43
+ ("peptidyl nitrile", "[NX3][CX4][CX2]#[NX1]"),
44
+ # Michael acceptors must be terminal to react. The general C=C-C(=O)N pattern also
45
+ # matches teriflunomide, whose alkene is an enol stabilised by an adjacent nitrile
46
+ # and hydroxyl - an approved drug, and not an electrophile.
47
+ ("acrylamide", "[CH2X3]=[CHX3][CX3](=O)[NX3]"),
48
+ ("vinyl sulfone", "[CX3]=[CX3][SX4](=O)(=O)"),
49
+ ("aldehyde", "[CX3H1](=O)[#6]"),
50
+ ("epoxide", "[OX2r3]1[#6r3][#6r3]1"),
51
+ ("aziridine", "[NX3r3]1[#6r3][#6r3]1"),
52
+ ("haloacetamide", "[NX3][CX3](=O)[CH2][F,Cl,Br,I]"),
53
+ ("boronic acid", "[BX3]([OX2H1])[OX2H1]"),
54
+ ("alpha-keto amide", "[CX3](=O)[CX3](=O)[NX3]"),
55
+ ]
56
+ _WARHEADS = [(n, Chem.MolFromSmarts(s)) for n, s in WARHEADS]
57
+
58
+
59
+ def canonical(text):
60
+ """Accept SMILES or InChI. A molecular formula cannot be accepted - too many isomers."""
61
+ t = (text or "").strip()
62
+ if not t:
63
+ return None, "빈 μž…λ ₯"
64
+ m = Chem.MolFromInchi(t) if t.upper().startswith("INCHI=") else Chem.MolFromSmiles(t)
65
+ if m is None:
66
+ return None, "ꡬ쑰λ₯Ό 해석할 수 μ—†μŠ΅λ‹ˆλ‹€ (SMILES λ˜λŠ” InChI둜 μ œμΆœν•˜μ„Έμš”; λΆ„μžμ‹μ€ μ΄μ„±μ§ˆμ²΄κ°€ λ§Žμ•„ 평가 λΆˆκ°€)"
67
+ return m, None
68
+
69
+
70
+ def check(text, ames=None, logs=None, known_inchikeys=(), covalent_rule=True):
71
+ """Return a verdict dict. `ames`/`logs` come from the ADMET engine; omit them and
72
+ those two gates are simply reported as not evaluated rather than silently passed.
73
+
74
+ `covalent_rule` is target-scoped on purpose. Excluding covalent binders makes sense
75
+ for Mpro, where warheads react with a catalytic cysteine and a docking score can be
76
+ gamed by raw reactivity. PfDHODH is not inhibited that way, so for Season 1 the rule
77
+ buys nothing and only creates false rejections - two approved drugs were wrongly
78
+ turned away here before the patterns were tightened."""
79
+ m, err = canonical(text)
80
+ if m is None:
81
+ return {"admitted": False, "reject": [err], "relegate": []}
82
+
83
+ smi = Chem.MolToSmiles(m)
84
+ key = Chem.MolToInchiKey(m)
85
+ mw = Descriptors.MolWt(m)
86
+ heavy = m.GetNumHeavyAtoms()
87
+
88
+ reject, relegate, notes = [], [], []
89
+
90
+ for name, patt in (_WARHEADS if covalent_rule else []):
91
+ if patt is not None and m.HasSubstructMatch(patt):
92
+ reject.append("κ³΅μœ κ²°ν•© warhead κ²€μΆœ (%s) β€” Season 1은 λΉ„κ³΅μœ  νŠΈλž™μž…λ‹ˆλ‹€" % name)
93
+ break
94
+
95
+ if mw > MW_MAX:
96
+ reject.append("λΆ„μžλŸ‰ %.1f > %.0f" % (mw, MW_MAX))
97
+ if heavy > HEAVY_MAX:
98
+ reject.append("무거운 μ›μž %d > %d" % (heavy, HEAVY_MAX))
99
+
100
+ hits = _PAINS.GetMatches(m)
101
+ if hits:
102
+ reject.append("PAINS ꡬ쑰 (%s)" % ", ".join(h.GetDescription() for h in hits)[:80])
103
+
104
+ if key in set(known_inchikeys):
105
+ reject.append("κΈ°μ‘΄ 제좜 λ˜λŠ” κΈ°μ€€λ¬Όμ§ˆκ³Ό λ™μΌν•œ κ΅¬μ‘°μž…λ‹ˆλ‹€")
106
+
107
+ # ADMET does not reject. It relegates: full score, shown, but below every clean entry.
108
+ if ames is None:
109
+ notes.append("Ames 미평가")
110
+ elif ames > AMES_MAX:
111
+ relegate.append("변이원성(Ames) %.3f > %.2f" % (ames, AMES_MAX))
112
+ if logs is None:
113
+ notes.append("μš©ν•΄λ„ 미평가")
114
+ elif logs < LOGS_MIN:
115
+ relegate.append("극단적 λΆˆμš©μ„± (logS %.2f < %.1f)" % (logs, LOGS_MIN))
116
+
117
+ return {"admitted": not reject, "reject": reject, "relegate": relegate,
118
+ "notes": notes, "smiles": smi, "inchikey": key,
119
+ "mw": round(mw, 2), "heavy_atoms": heavy}
120
+
121
+
122
+ if __name__ == "__main__":
123
+ import json, os, sys
124
+ # the Windows console defaults to cp949 here and dies on the em-dash in a reject reason
125
+ try:
126
+ sys.stdout.reconfigure(encoding="utf-8")
127
+ except Exception:
128
+ pass
129
+ A = json.load(open(os.path.join("data", "anchors.json"), encoding="utf-8"))
130
+ print("%-30s %-9s %-7s %-6s %s" % ("compound", "admitted", "MW", "heavy", "reason"))
131
+ print("-" * 100)
132
+ for a in A["anchors"]:
133
+ ad = a.get("measured_admet", {})
134
+ v = check(a["smiles"], ames=ad.get("ames"), logs=ad.get("solubility-aqsoldb"))
135
+ why = "; ".join(v["reject"] + ["[κ°•λ“±] " + r for r in v["relegate"]]) or "-"
136
+ print("%-30s %-9s %-7s %-6s %s"
137
+ % (a["label"][:29], "PASS" if v["admitted"] else "REJECT",
138
+ v.get("mw"), v.get("heavy_atoms"), why[:60]))
index.html ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Open Discovery Challenge #1 Malaria</title>
4
+ <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <style>
6
+ :root{
7
+ --bg:#0e1116; --panel:#161b23; --line:#252c38; --ink:#e6edf5; --muted:#8b98ab;
8
+ --accent:#2fe7d6; --anchor:#f0b429; --bad:#e5534b; --ok:#3fb950;
9
+ }
10
+ *{box-sizing:border-box}
11
+ body{margin:0;background:var(--bg);color:var(--ink);
12
+ font:15px/1.6 -apple-system,"Segoe UI",Roboto,"Malgun Gothic",sans-serif}
13
+ .wrap{max-width:1120px;margin:0 auto;padding:28px 18px 80px}
14
+ header{border-bottom:1px solid var(--line);padding-bottom:18px;margin-bottom:26px}
15
+ h1{margin:0 0 4px;font-size:26px;letter-spacing:-.2px}
16
+ h1 small{color:var(--accent);font-size:13px;letter-spacing:2px;display:block;
17
+ text-transform:uppercase;margin-bottom:6px;font-weight:600}
18
+ .sub{color:var(--muted);font-size:13.5px;margin:0}
19
+ .grid{display:grid;grid-template-columns:1fr 340px;gap:22px;align-items:start}
20
+ @media(max-width:900px){.grid{grid-template-columns:1fr}}
21
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
22
+ .card h2{margin:0 0 12px;font-size:14px;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
23
+ table{width:100%;border-collapse:collapse;font-size:13.5px}
24
+ th{text-align:right;color:var(--muted);font-weight:600;font-size:11px;
25
+ letter-spacing:.6px;text-transform:uppercase;padding:0 8px 8px;border-bottom:1px solid var(--line)}
26
+ th:nth-child(-n+3){text-align:left}
27
+ td{padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.04);text-align:right}
28
+ td:nth-child(-n+3){text-align:left}
29
+ tr.anchor{background:rgba(240,180,41,.07)}
30
+ tr.anchor td{color:#f4d089}
31
+ tr.tier2{opacity:.65}
32
+ .rk{width:38px;font-variant-numeric:tabular-nums;color:var(--muted)}
33
+ .cid{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px}
34
+ .tot{font-weight:700;font-size:15px;font-variant-numeric:tabular-nums}
35
+ .who{color:var(--muted);font-size:12.5px}
36
+ .badge{display:inline-block;font-size:10px;letter-spacing:.6px;padding:2px 6px;
37
+ border-radius:5px;background:rgba(240,180,41,.16);color:var(--anchor);margin-left:6px}
38
+ .badge.rel{background:rgba(229,83,75,.15);color:var(--bad)}
39
+ label{display:block;font-size:12px;color:var(--muted);margin:12px 0 4px;letter-spacing:.4px}
40
+ input,textarea{width:100%;background:#0d1117;border:1px solid var(--line);border-radius:8px;
41
+ color:var(--ink);padding:9px 11px;font:inherit;font-size:13.5px}
42
+ textarea{resize:vertical;min-height:56px}
43
+ button{width:100%;margin-top:16px;background:var(--accent);color:#06231f;border:0;
44
+ border-radius:8px;padding:11px;font:inherit;font-weight:700;cursor:pointer}
45
+ button:disabled{opacity:.5;cursor:default}
46
+ .msg{margin-top:12px;font-size:12.5px;padding:10px 12px;border-radius:8px;display:none}
47
+ .msg.ok{display:block;background:rgba(63,185,80,.12);color:var(--ok)}
48
+ .msg.err{display:block;background:rgba(229,83,75,.12);color:var(--bad)}
49
+ .note{color:var(--muted);font-size:11.5px;line-height:1.65;margin-top:14px;
50
+ border-top:1px solid var(--line);padding-top:12px}
51
+ .wts{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
52
+ .wts span{font-size:11px;color:var(--muted);background:#0d1117;border:1px solid var(--line);
53
+ border-radius:20px;padding:3px 10px}
54
+ </style>
55
+
56
+ <div class="wrap">
57
+ <header>
58
+ <h1><small>Open Discovery Challenge</small>#1 &nbsp;Malaria</h1>
59
+ <p class="sub">
60
+ AIκ°€ μ œμ•ˆν•œ μ €λΆ„μž 후보λ₯Ό 곡개된 κΈ°μ€€μœΌλ‘œ μžλ™ μ±„μ ν•˜λŠ” κ°œλ°©ν˜• μ‹ μ•½ 발꡴ λŒ€νšŒ.
61
+ ν‘œμ μ€ <b>PfDHODH</b>(말라리아 원좩 νš¨μ†Œ), μ—­ν‘œμ μ€ <b>μ‚¬λžŒ DHODH</b>μž…λ‹ˆλ‹€.
62
+ </p>
63
+ <div class="wts" id="wts"></div>
64
+ </header>
65
+
66
+ <div class="grid">
67
+ <div class="card">
68
+ <h2>μˆœμœ„ν‘œ <span id="counts" style="float:right;font-size:11px"></span></h2>
69
+ <table>
70
+ <thead><tr>
71
+ <th class="rk">#</th><th>후보</th><th>제좜자 / λͺ¨λΈ</th>
72
+ <th>μ•½νš¨</th><th>κ²°ν•©</th><th>선택성</th><th>ADMET</th><th>μ‹ κ·œ</th><th>ν•©μ„±</th><th>총점</th>
73
+ </tr></thead>
74
+ <tbody id="rows"><tr><td colspan="10" class="who">λΆˆλŸ¬μ˜€λŠ” 쀑…</td></tr></tbody>
75
+ </table>
76
+ <div class="note">
77
+ κΈ°μ€€λ¬Όμ§ˆ(λ…Έλž€ ν–‰)은 점수λ₯Ό λ°›λ˜ <b>λ“±μˆ˜λ₯Ό κ°–μ§€ μ•ŠμŠ΅λ‹ˆλ‹€</b>. 척도λ₯Ό 눈으둜 ν™•μΈν•˜μ‹œλΌκ³  넣은 κ²ƒμž…λ‹ˆλ‹€.<br>
78
+ κ΅¬μ‘°λŠ” 전체 κ³΅κ°œν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. 골격 ν•΄μ‹œμ™€ λΆ„μžλŸ‰ κ΅¬κ°„λ§Œ ν‘œμ‹œλ˜λ©°, 원본은 λΉ„κ³΅κ°œλ‘œ λ³΄κ΄€λ©λ‹ˆλ‹€.<br>
79
+ μ μˆ˜λŠ” <b>계산 기반 후보 평가</b>이며 μ‹€μ œ 효λŠ₯Β·μ•ˆμ „μ„±μ΄λ‚˜ μŠΉμΈμ•½κ³Όμ˜ μš°μ—΄μ„ λœ»ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
80
+ </div>
81
+ </div>
82
+
83
+ <div class="card">
84
+ <h2>제좜</h2>
85
+ <label>λΆ„μž ꡬ쑰식 (SMILES λ˜λŠ” InChI)</label>
86
+ <input id="structure" placeholder="예: CC1=NC2=NC(=NN2C(=C1)N...)">
87
+ <label>ν‘œμ‹œ ID (μ΄λ¦„Β·μ†Œμ†Β·λ‹‰λ„€μž„)</label>
88
+ <input id="display_name" placeholder="곡개 μˆœμœ„ν‘œμ— ν‘œμ‹œλ©λ‹ˆλ‹€">
89
+ <label>μ‚¬μš© λͺ¨λΈλͺ… (선택)</label>
90
+ <input id="model_name" placeholder="예: anthropic/claude-sonnet, Qwen/…, 직접 섀계">
91
+ <label>섀계 κ·Όκ±° (선택)</label>
92
+ <textarea id="rationale" placeholder="μ™œ 이 ꡬ쑰인지 κ°„λ‹¨νžˆ"></textarea>
93
+ <button id="go">제좜</button>
94
+ <div class="msg" id="msg"></div>
95
+ <div class="note">
96
+ λΆ„μžμ‹(C20H25N3O4 λ“±)은 λ°›μ§€ μ•ŠμŠ΅λ‹ˆλ‹€ β€” μ΄μ„±μ§ˆμ²΄κ°€ λ„ˆλ¬΄ λ§Žμ•„ 평가가 λΆˆκ°€λŠ₯ν•©λ‹ˆλ‹€.<br>
97
+ 제좜 μ¦‰μ‹œ ꡬ쑰 검사λ₯Ό 거치고, 채점은 GPU μž‘μ—… λŒ€κΈ°μ—΄μ—μ„œ μ²˜λ¦¬λ©λ‹ˆλ‹€(수 λΆ„ μ†Œμš”).
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <script>
104
+ const $ = s => document.querySelector(s);
105
+ const AX = ["activity","binding","selectivity","admet","novelty","synthesis"];
106
+ const f1 = v => (v==null||v==="") ? "–" : (+v).toFixed(1);
107
+
108
+ async function load(){
109
+ const d = await (await fetch("api/leaderboard")).json();
110
+ $("#wts").innerHTML = Object.entries(d.season.weights)
111
+ .map(([k,v]) => `<span>${k} ${v}</span>`).join("");
112
+ $("#counts").textContent = `제좜 ${d.counts.scored} Β· κΈ°μ€€λ¬Όμ§ˆ ${d.counts.anchors}`;
113
+
114
+ const tb = $("#rows");
115
+ if(!d.entries.length){ tb.innerHTML = '<tr><td colspan="10" class="who">아직 μ±„μ λœ 제좜이 μ—†μŠ΅λ‹ˆλ‹€.</td></tr>'; return; }
116
+ tb.innerHTML = d.entries.map(e => {
117
+ const a = e.axes || {};
118
+ const cls = [e.is_anchor ? "anchor" : "", e.tier===2 ? "tier2" : ""].join(" ").trim();
119
+ const who = e.is_anchor ? (e.note||"κΈ°μ€€λ¬Όμ§ˆ")
120
+ : `${e.display_name||"–"}${e.model_name?` <span class="who">Β· ${e.model_name}</span>`:""}`;
121
+ const id = e.is_anchor ? e.candidate_id
122
+ : `<span class="cid">${e.candidate_id}</span>` +
123
+ (e.skeleton?` <span class="who">${e.skeleton}</span>`:"");
124
+ const flag = e.is_anchor ? '<span class="badge">κΈ°μ€€</span>'
125
+ : (e.tier===2 ? '<span class="badge rel">κ°•λ“±</span>' : "");
126
+ return `<tr class="${cls}">
127
+ <td class="rk">${e.rank ?? "β€”"}</td>
128
+ <td>${id}${flag}</td><td>${who}</td>
129
+ ${AX.map(k=>`<td>${f1(a[k])}</td>`).join("")}
130
+ <td class="tot">${f1(e.total)}</td></tr>`;
131
+ }).join("");
132
+ }
133
+
134
+ $("#go").onclick = async () => {
135
+ const btn = $("#go"), msg = $("#msg");
136
+ const body = {
137
+ structure: $("#structure").value.trim(),
138
+ display_name: $("#display_name").value.trim(),
139
+ model_name: $("#model_name").value.trim(),
140
+ rationale: $("#rationale").value.trim(),
141
+ };
142
+ if(!body.structure || !body.display_name){
143
+ msg.className = "msg err"; msg.textContent = "ꡬ쑰와 ν‘œμ‹œ IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."; return;
144
+ }
145
+ btn.disabled = true; msg.className = "msg"; msg.textContent = "";
146
+ try{
147
+ const r = await fetch("api/submit", {method:"POST",
148
+ headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
149
+ const d = await r.json();
150
+ if(d.accepted){
151
+ msg.className = "msg ok";
152
+ msg.textContent = `μ ‘μˆ˜λ˜μ—ˆμŠ΅λ‹ˆλ‹€ Β· ${d.candidate_id} Β· λŒ€κΈ° ${d.queue_position}건. ${d.note}`;
153
+ $("#structure").value = "";
154
+ load();
155
+ }else{
156
+ msg.className = "msg err";
157
+ msg.textContent = (d.reasons||["제좜이 κ±°λΆ€λ˜μ—ˆμŠ΅λ‹ˆλ‹€"]).join(" / ");
158
+ }
159
+ }catch(e){
160
+ msg.className = "msg err"; msg.textContent = "μš”μ²­ μ‹€νŒ¨: " + e;
161
+ }
162
+ btn.disabled = false;
163
+ };
164
+
165
+ load();
166
+ setInterval(load, 20000);
167
+ </script>
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ pydantic==2.10.4
4
+ rdkit==2024.9.4