SeaWolf-AI commited on
Commit
f000a32
·
verified ·
1 Parent(s): 3825ff2

Participation guide, prompt set, and per-model standings

Browse files
Files changed (4) hide show
  1. app.py +54 -2
  2. index.html +238 -2
  3. seasons.py +1 -1
  4. store.py +2 -6
app.py CHANGED
@@ -14,6 +14,7 @@ import hashlib
14
  import hmac
15
  import json
16
  import os
 
17
  import secrets
18
  import time
19
  import urllib.parse
@@ -128,8 +129,7 @@ def _redirect_uri(request: Request):
128
  # ---------------------------------------------------------------- 화면
129
  @app.get("/")
130
  async def index():
131
- # async 유: sync 라우트는 요청 스레드풀에서 돈다. 풀이 Hub I/O 막히면
132
- # 정적 페이지까지 뒤에 줄을 서서 사이트가 죽은 것처럼 보인다.
133
  return FileResponse(os.path.join(HERE, "index.html"))
134
 
135
 
@@ -313,6 +313,58 @@ def leaderboard(request: Request):
313
  headers={"Vary": "Accept-Encoding"})
314
 
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  @app.get("/api/queue")
317
  def queue(request: Request):
318
  season = seasons.get(request.query_params.get("season"))
 
14
  import hmac
15
  import json
16
  import os
17
+ import re
18
  import secrets
19
  import time
20
  import urllib.parse
 
129
  # ---------------------------------------------------------------- 화면
130
  @app.get("/")
131
  async def index():
132
+ # 정적 는 요청 스레드풀 타지 않게 async둔다.
 
133
  return FileResponse(os.path.join(HERE, "index.html"))
134
 
135
 
 
313
  headers={"Vary": "Accept-Encoding"})
314
 
315
 
316
+ _FILLER = {"ai", "model", "llm", "the", "v", "ver", "version", "latest", "preview",
317
+ "chat", "instruct", "it", "api", "official", "new"}
318
+
319
+
320
+ def canon_model(name):
321
+ """표기가 갈린 같은 모델을 하나로 묶는다.
322
+
323
+ 'Anthropic Claude Opus 5' 와 'claude-opus-5' 는 같은 것이고, 따로 세면 둘 다
324
+ 실제보다 적게 쓰인 것처럼 보인다. 다만 변종을 구분하는 낱말(Pro/Flash 등)은
325
+ 지우지 않는다 - 그것까지 뭉치면 다른 모델이 한 칸에 들어간다.
326
+ """
327
+ s = re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip()
328
+ toks = [t for t in s.split() if t and t not in _FILLER]
329
+ return " ".join(toks) or (name or "").strip().lower()
330
+
331
+
332
+ @app.get("/api/models")
333
+ def models(request: Request):
334
+ """모델별 성적: 어느 모델이 가장 좋은 후보를 냈는가, 그리고 얼마나 쓰이는가.
335
+
336
+ 두 수를 **따로** 낸다. 많이 쓰인다고 잘하는 것이 아니다 - 다들 집는 모델은 점수와
337
+ 무관하게 제출 수가 쌓이고, 세 번 쓰인 모델이 1위 후보를 갖고 있을 수 있다.
338
+ 평균만 내면 둘 다 가려진다. 그래서 최고·평균·건수를 나란히 둔다.
339
+ 기준물질은 제외한다 - 참가자의 모델이 만든 것이 아니다.
340
+ """
341
+ season = seasons.get(request.query_params.get("season"))
342
+ agg = {}
343
+ for r in (_board(season).get("entries") or {}).values():
344
+ if r.get("total") is None:
345
+ continue
346
+ name = (r.get("model_name") or "").strip() or "미기재 / unspecified"
347
+ key = canon_model(name)
348
+ a = agg.setdefault(key, {"n": 0, "best": None, "sum": 0.0,
349
+ "top_candidate": None, "spellings": {}})
350
+ a["spellings"][name] = a["spellings"].get(name, 0) + 1
351
+ a["n"] += 1
352
+ a["sum"] += r["total"]
353
+ if a["best"] is None or r["total"] > a["best"]:
354
+ a["best"] = r["total"]
355
+ a["top_candidate"] = r.get("candidate_id")
356
+ out = []
357
+ for k, a in agg.items():
358
+ # 참가자들이 실제로 가장 많이 쓴 표기를 이름으로 삼는다
359
+ label = max(a["spellings"].items(), key=lambda kv: kv[1])[0]
360
+ out.append({"model": label, "n": a["n"], "best": round(a["best"], 2),
361
+ "mean": round(a["sum"] / a["n"], 2),
362
+ "top_candidate": a["top_candidate"]})
363
+ out.sort(key=lambda x: (-x["best"], -x["n"]))
364
+ return {"models": out, "counts": {"models": len(out),
365
+ "entries": sum(m["n"] for m in out)}}
366
+
367
+
368
  @app.get("/api/queue")
369
  def queue(request: Request):
370
  season = seasons.get(request.query_params.get("season"))
index.html CHANGED
@@ -86,6 +86,34 @@
86
  a{color:var(--accent)}
87
  .badges{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
88
  .badges img{height:22px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  </style>
90
  </head>
91
  <body>
@@ -110,6 +138,21 @@
110
  <div class="callout" id="t_honest"></div>
111
  </div>
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  <div class="grid">
114
  <div class="card">
115
  <h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
@@ -167,6 +210,24 @@ const T = {
167
  <b>이온전도도는 이번 시즌의 채점 항목이 아닙니다.</b> 따라서 전도도가 높은 계열이라도
168
  산화창이 좁거나 리튬 금속과 반응하면 낮게 나옵니다 — 그 두 가지가 이 시즌이 묻는 문제입니다.
169
  모든 값은 <b>계산 추정치</b>이며 실제 성능이나 안전성을 뜻하지 않습니다.`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  board_h:"순위표",
171
  boardnote:`<b>기준물질(노란 행)</b>은 점수를 받되 등수를 갖지 않습니다. 실제로 쓰이는 전해질이 몇 점인지
172
  눈으로 비교하시라고 넣었습니다.<br>
@@ -210,6 +271,24 @@ const T = {
210
  scores low if its oxidation window is narrow or it reacts with lithium metal — those two are the problem
211
  this season puts to you.
212
  Every value is a <b>computational estimate</b> and implies nothing about real performance or safety.`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  board_h:"Leaderboard",
214
  boardnote:`<b>Reference materials (amber rows)</b> are scored but hold no rank. They are there so you can see
215
  where materials in actual use land.<br>
@@ -242,6 +321,101 @@ const T = {
242
  }
243
  };
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  const $ = s => document.querySelector(s);
246
  const COLS = ["rank","cand","user","who","date","oxidation","li_stability","migration","novelty","total"];
247
  // 표가 커지면 한꺼번에 그리지 않고 한 페이지씩 그린다.
@@ -267,6 +441,13 @@ function setLang(l){
267
  $("#t_broker_h").textContent = d.broker_h;
268
  $("#t_broker").innerHTML = d.broker;
269
  $("#t_cifnote").innerHTML = d.cifnote;
 
 
 
 
 
 
 
270
  $("#t_priv").textContent = d.priv;
271
  $("#t_pub").textContent = d.pub;
272
  $("#go").textContent = d.go;
@@ -284,7 +465,8 @@ function renderSeasonBar(){
284
  #${s.number} ${esc(nm)}</button>`;
285
  }).join("");
286
  document.querySelectorAll("#seasonbar button").forEach(b=>
287
- b.onclick=()=>{ SEASON=parseInt(b.dataset.season,10); page=0; renderSeasonBar(); load(); });
 
288
  }
289
 
290
  function sortVal(e,k){
@@ -297,6 +479,58 @@ function sortVal(e,k){
297
  return (e.axes||{})[k] ?? -1;
298
  }
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  function renderPager(total,pages){
301
  const host = $("#pager"), d = t();
302
  if(pages<=1){ host.innerHTML=""; return; }
@@ -397,7 +631,7 @@ $("#go").onclick = async () => {
397
  const j = await r.json();
398
  if(!r.ok){ m.className="msg bad"; m.textContent = j.detail || ("HTTP "+r.status); }
399
  else{ m.className="msg ok"; m.innerHTML = d.ok(j.candidate_id, j.queue_position);
400
- $("#f_formula").value=""; $("#f_cif").value=""; load(); }
401
  }catch(e){ m.className="msg bad"; m.textContent=String(e); }
402
  $("#go").disabled = false;
403
  };
@@ -417,7 +651,9 @@ $("#go").onclick = async () => {
417
  }catch(e){}
418
  setLang("ko");
419
  await load();
 
420
  setInterval(load, 20000);
 
421
  })();
422
  </script>
423
  </body>
 
86
  a{color:var(--accent)}
87
  .badges{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
88
  .badges img{height:22px}
89
+ .lead{font-size:15px;margin:0 0 12px}
90
+ .facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin:12px 0}
91
+ .fact{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:11px 13px;font-size:13px}
92
+ .fact b{display:block;color:var(--accent);font-size:12px;margin-bottom:3px}
93
+ .pcard{border:1px solid var(--line);border-radius:11px;margin:10px 0;overflow:hidden}
94
+ .phead{display:flex;align-items:center;gap:9px;padding:9px 13px;background:var(--bg);
95
+ border-bottom:1px solid var(--line);flex-wrap:wrap}
96
+ .ptag{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;
97
+ border-radius:6px;background:var(--accent);color:#fff;font-size:12px;font-weight:700}
98
+ .phead b{font-size:14px} .phead span{color:var(--muted);font-size:12.5px}
99
+ .pcopy{margin-left:auto;border:1px solid var(--line);background:var(--card);color:var(--muted);
100
+ border-radius:7px;padding:4px 11px;cursor:pointer;font:inherit;font-size:12px}
101
+ .pcopy:hover{border-color:var(--accent);color:var(--accent)}
102
+ .pbody{padding:12px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
103
+ font-size:12.5px;line-height:1.6;white-space:pre-wrap;color:var(--ink);max-height:280px;overflow:auto}
104
+ .pbody em{color:var(--warn);font-style:normal;font-weight:600}
105
+ .mrow{display:grid;grid-template-columns:minmax(120px,1.4fr) 1fr auto;gap:10px;
106
+ align-items:center;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px}
107
+ .mrow:last-child{border-bottom:0}
108
+ .mname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
109
+ .mbar{position:relative;height:16px;background:var(--bg);border-radius:5px;overflow:hidden}
110
+ .mbar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:5px}
111
+ .mbar u{position:absolute;top:0;bottom:0;width:2px;background:var(--ink);opacity:.55}
112
+ .mnum{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap;font-size:12.5px}
113
+ .mlegend{display:flex;gap:14px;color:var(--muted);font-size:12px;margin-top:10px;flex-wrap:wrap}
114
+ .mlegend i{display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:4px}
115
+ .mlegend u{display:inline-block;width:2px;height:11px;background:var(--ink);opacity:.55;margin-right:4px;
116
+ vertical-align:-1px;text-decoration:none}
117
  </style>
118
  </head>
119
  <body>
 
138
  <div class="callout" id="t_honest"></div>
139
  </div>
140
 
141
+ <div class="card">
142
+ <h2 id="t_guide_h"></h2>
143
+ <p class="lead" id="t_guide_lead"></p>
144
+ <div class="facts" id="facts"></div>
145
+ <div id="t_guide_how"></div>
146
+ <div id="prompts"></div>
147
+ </div>
148
+
149
+ <div class="card">
150
+ <h2><span id="t_models_h"></span><span class="cnt" id="mcounts"></span></h2>
151
+ <div id="t_models_lead" class="note" style="margin-top:0"></div>
152
+ <div id="models"></div>
153
+ <div class="mlegend" id="t_mlegend"></div>
154
+ </div>
155
+
156
  <div class="grid">
157
  <div class="card">
158
  <h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
 
210
  <b>이온전도도는 이번 시즌의 채점 항목이 아닙니다.</b> 따라서 전도도가 높은 계열이라도
211
  산화창이 좁거나 리튬 금속과 반응하면 낮게 나옵니다 — 그 두 가지가 이 시즌이 묻는 문제입니다.
212
  모든 값은 <b>계산 추정치</b>이며 실제 성능이나 안전성을 뜻하지 않습니다.`,
213
+ guide_h:"참가 가이드",
214
+ guide_lead:`전고체 전지는 리튬 이온이 <b>고체 안을</b> 지나가야 합니다. 그런데 그 물질은 동시에
215
+ <b>전자는 통과시키지 않아야</b> 하고, 충전 전압을 견뎌야 하고, 음극의 <b>리튬 금속</b>에 닿아도
216
+ 분해되지 않아야 합니다. 이 네 가지를 한 물질에서 동시에 만족시키는 것이 아직 풀리지 않은 문제입니다.`,
217
+ facts:[
218
+ ["표적", "리튬이 지나갈 통로가 있는 고체. 리튬을 반드시 포함해야 합니다."],
219
+ ["넘어야 할 관문", "전자가 통하면 전지가 그대로 단락됩니다. 전자 절연성이 전제 조건입니다."],
220
+ ["가장 어려운 곳", "산화 안정성과 리튬금속 안정성은 서로 잘 안 맞습니다. 둘 다 잡는 것이 이 시즌의 과제입니다."],
221
+ ["제출", "조성 하나. 구조(CIF)는 선택이며, 내면 채점이 더 빠르고 정확합니다."]],
222
+ guide_how:`<p style="margin:14px 0 2px">아래 프롬프트를 쓰시는 AI 모델에 그대로 넣으시면 됩니다.
223
+ <b>복사</b> 버튼으로 가져가신 뒤, 기울임체로 표시된 부분은 본인 관심사로 바꿔 주십시오 —
224
+ 그래야 다른 참가자와 같은 후보가 겹치지 않습니다. 나온 조성을 오른쪽 칸에 하나씩 제출하시면 됩니다.</p>`,
225
+ models_h:"AI 모델별 성과",
226
+ models_lead:`어느 모델이 <b>가장 좋�� 후보</b>를 냈는지(막대)와 <b>얼마나 쓰이는지</b>(오른쪽)를
227
+ 함께 봅니다. 많이 쓰인다고 잘하는 것은 아니므로 따로 표시합니다.`,
228
+ mempty:"채점된 제출이 쌓이면 모델별 성과가 여기에 표시됩니다.",
229
+ mcounts:(m,n)=>`모델 ${m} · 제출 ${n}`,
230
+ mlegend:`<span><i></i>최고 점수</span><span><u></u>평균</span><span>제출 수</span>`,
231
  board_h:"순위표",
232
  boardnote:`<b>기준물질(노란 행)</b>은 점수를 받되 등수를 갖지 않습니다. 실제로 쓰이는 전해질이 몇 점인지
233
  눈으로 비교하시라고 넣었습니다.<br>
 
271
  scores low if its oxidation window is narrow or it reacts with lithium metal — those two are the problem
272
  this season puts to you.
273
  Every value is a <b>computational estimate</b> and implies nothing about real performance or safety.`,
274
+ guide_h:"How to take part",
275
+ guide_lead:`In a solid-state cell the lithium ion has to travel <b>through a solid</b>. That same solid
276
+ must <b>block electrons</b>, hold up at the charging voltage, and survive contact with the
277
+ <b>lithium metal</b> anode without decomposing. Meeting all four in one material is the open problem.`,
278
+ facts:[
279
+ ["Target", "A solid with a path for lithium to move through. It must contain lithium."],
280
+ ["Get past this", "If electrons pass, the cell simply shorts. Electronic insulation is a precondition."],
281
+ ["Hardest part", "Oxidation stability and lithium-metal stability pull against each other. Holding both is the task."],
282
+ ["Submission", "One composition. A structure (CIF) is optional and makes scoring faster and more accurate."]],
283
+ guide_how:`<p style="margin:14px 0 2px">Paste a prompt below into the model you use. Hit <b>Copy</b>,
284
+ then change the italicised part to something of your own — otherwise your candidates will collide with
285
+ other entrants'. Submit the compositions it returns, one at a time, on the right.</p>`,
286
+ models_h:"By model",
287
+ models_lead:`Which model produced the <b>best candidate</b> (bar) and <b>how often it is used</b> (right).
288
+ Being used a lot is not the same as doing well, so the two are shown separately.`,
289
+ mempty:"Model standings appear here once scored entries accumulate.",
290
+ mcounts:(m,n)=>`${m} models · ${n} entries`,
291
+ mlegend:`<span><i></i>Best score</span><span><u></u>Mean</span><span>Entries</span>`,
292
  board_h:"Leaderboard",
293
  boardnote:`<b>Reference materials (amber rows)</b> are scored but hold no rank. They are there so you can see
294
  where materials in actual use land.<br>
 
321
  }
322
  };
323
 
324
+ // 프롬프트는 **목표**만 말한다. 무엇을 어떻게 재는지는 적지 않는다 - 적으면 물질이 아니라
325
+ // 채점기를 겨냥한 제출이 들어온다.
326
+ const PROMPTS_BY_SEASON = {
327
+ 1: [
328
+ {id:"A", ko:["기본형","처음 시작한다면 이것부터"], en:["Starter","Begin here"],
329
+ body:`You are proposing candidate solid electrolytes for an all-solid-state lithium battery,
330
+ for an open materials challenge.
331
+
332
+ Requirements for every candidate:
333
+ - Contains lithium, with a plausible connected pathway for Li+ to migrate through the
334
+ lattice.
335
+ - An electronic insulator. If electrons conduct, the cell short-circuits; this is a
336
+ precondition, not a trade-off.
337
+ - Wide electrochemical window: stable against oxidation at high cell voltage.
338
+ - Stable in contact with lithium metal at the anode, or decomposing only into a passivating
339
+ layer rather than an electronically conductive one.
340
+ - Plausibly synthesisable: a composition close to a stable phase, not an exotic guess.
341
+
342
+ Propose 8 candidate compositions as a JSON array:
343
+ [{"formula":"...","name":"...","rationale":"why lithium should move through it, why it
344
+ should not conduct electrons, and how it behaves against lithium metal"}]
345
+
346
+ Use plain chemical formulae, e.g. Li3YCl6 or LiZr2(PO4)3.
347
+
348
+ <em>VARY THIS: name a chemical family you want to explore (halides, oxides, phosphates,
349
+ oxyhalides, anti-perovskites, …) or a constraint of your own, so your set does not collide
350
+ with other entrants'. Identical submissions are rejected.</em>`},
351
+
352
+ {id:"B", ko:["산화 안정성","높은 전압을 견디는 설계 (35점)"], en:["Oxidation","Holding up at high voltage (35 pts)"],
353
+ body:`Propose solid lithium-ion electrolytes designed specifically for a WIDE oxidation
354
+ window, so they survive against a high-voltage cathode.
355
+
356
+ Reason from this:
357
+ - The anion is usually what oxidises first. Sulfide frameworks conduct well but oxidise at
358
+ low potential; more electronegative anions push the oxidation limit higher.
359
+ - The counter-cation matters: a cation already in a high oxidation state has less room to
360
+ be oxidised further.
361
+ - A material that resists oxidation but has no lithium pathway is worthless. A connected
362
+ migration path is a precondition, not a trade-off.
363
+
364
+ For each candidate state explicitly: which anion framework you chose and why you expect it
365
+ to resist oxidation, and how lithium still moves through it.
366
+
367
+ Propose 8 candidates as JSON: [{"formula","name","rationale"}]
368
+
369
+ <em>VARY THIS: commit to ONE anion chemistry and build the whole set around it, rather than
370
+ eight unrelated guesses.</em>`},
371
+
372
+ {id:"C", ko:["리튬금속 안정성","음극에서 버티는 설계 (30점)"], en:["Lithium-metal stability","Surviving the anode (30 pts)"],
373
+ body:`Propose solid lithium-ion electrolytes designed to survive direct contact with
374
+ LITHIUM METAL.
375
+
376
+ Reason from this:
377
+ - Lithium metal is strongly reducing. Any cation in the electrolyte that can be reduced
378
+ will be. Transition metals in high oxidation states are the classic failure: they reduce,
379
+ the interface becomes electronically conductive, and the reaction never stops.
380
+ - The useful case is a decomposition product that is itself an insulator, so the interface
381
+ passivates and the reaction halts.
382
+ - Wide oxidation window alone does not help if the anode side fails. Both ends of the
383
+ window matter.
384
+
385
+ For each candidate state explicitly: which element you expect lithium metal to attack, and
386
+ why the interface should stop reacting rather than keep going.
387
+
388
+ Propose 8 candidates as JSON: [{"formula","name","rationale"}]
389
+
390
+ <em>VARY THIS: choose one strategy — reduction-proof cations, or a self-passivating
391
+ decomposition product — and build the set around it.</em>`},
392
+
393
+ {id:"D", ko:["용도 신규성","가장 큰 점수 여지 (20점)"], en:["Use novelty","Where the headroom is (20 pts)"],
394
+ body:`Propose lithium-containing compositions that are NOT already reported as solid
395
+ electrolytes.
396
+
397
+ Important distinction: the composition does not have to be brand new. What matters is
398
+ whether anyone has proposed it FOR THIS USE. A known compound that nobody has evaluated as
399
+ a lithium electrolyte is exactly the target; a well-studied electrolyte family is not.
400
+
401
+ So avoid the ones already worked over — the common sulfide, garnet, NASICON and
402
+ argyrodite electrolyte families — and look at lithium compounds studied for other reasons
403
+ entirely, or not studied much at all.
404
+
405
+ Every candidate must still meet the basics: a lithium migration path, electronic
406
+ insulation, and reasonable stability at both electrodes. Novelty with no function scores
407
+ nothing.
408
+
409
+ For each candidate state: what the composition is normally known for (if anything), and
410
+ why it might work as an electrolyte.
411
+
412
+ Propose 8 candidates as JSON: [{"formula","name","rationale"}]
413
+
414
+ <em>VARY THIS: pick one source of overlooked compounds — a mineral class, a family studied
415
+ for optics or ionics elsewhere, a ternary system that is sparsely mapped — and work it.</em>`}
416
+ ]
417
+ };
418
+
419
  const $ = s => document.querySelector(s);
420
  const COLS = ["rank","cand","user","who","date","oxidation","li_stability","migration","novelty","total"];
421
  // 표가 커지면 한꺼번에 그리지 않고 한 페이지씩 그린다.
 
441
  $("#t_broker_h").textContent = d.broker_h;
442
  $("#t_broker").innerHTML = d.broker;
443
  $("#t_cifnote").innerHTML = d.cifnote;
444
+ $("#t_guide_h").textContent = d.guide_h;
445
+ $("#t_guide_lead").innerHTML = d.guide_lead;
446
+ $("#t_guide_how").innerHTML = d.guide_how;
447
+ $("#t_models_h").textContent = d.models_h;
448
+ $("#t_models_lead").innerHTML = d.models_lead;
449
+ renderFacts();
450
+ renderPrompts();
451
  $("#t_priv").textContent = d.priv;
452
  $("#t_pub").textContent = d.pub;
453
  $("#go").textContent = d.go;
 
465
  #${s.number} ${esc(nm)}</button>`;
466
  }).join("");
467
  document.querySelectorAll("#seasonbar button").forEach(b=>
468
+ b.onclick=()=>{ SEASON=parseInt(b.dataset.season,10); page=0;
469
+ renderSeasonBar(); renderPrompts(); load(); loadModels(); });
470
  }
471
 
472
  function sortVal(e,k){
 
479
  return (e.axes||{})[k] ?? -1;
480
  }
481
 
482
+ function promptsFor(){ return PROMPTS_BY_SEASON[SEASON] || PROMPTS_BY_SEASON[1]; }
483
+
484
+ function renderPrompts(){
485
+ const d = t(), host = $("#prompts");
486
+ if(!host) return;
487
+ host.innerHTML = promptsFor().map((p,i)=>{
488
+ const [title,sub] = (LANG==="ko" ? p.ko : p.en);
489
+ return `<div class="pcard">
490
+ <div class="phead"><span class="ptag">${p.id}</span><b>${esc(title)}</b><span>${esc(sub)}</span>
491
+ <button class="pcopy" data-i="${i}">${LANG==="ko"?"복사":"Copy"}</button></div>
492
+ <div class="pbody">${p.body.trim()}</div></div>`;
493
+ }).join("");
494
+ host.querySelectorAll(".pcopy").forEach(b=>b.onclick=async()=>{
495
+ // 강조 표시는 화면용이다. 클립보드에는 깨끗한 본문만 넣는다.
496
+ const raw = promptsFor()[+b.dataset.i].body.replace(/<\/?em>/g,"").trim();
497
+ try{ await navigator.clipboard.writeText(raw); }
498
+ catch(_){ const ta=document.createElement("textarea"); ta.value=raw;
499
+ document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); }
500
+ const was=b.textContent;
501
+ b.textContent = LANG==="ko" ? "복사됨 ✓" : "Copied ✓";
502
+ setTimeout(()=>{ b.textContent=was; },1500);
503
+ });
504
+ }
505
+
506
+ function renderFacts(){
507
+ const d = t();
508
+ $("#facts").innerHTML = (d.facts||[]).map(([k,v])=>
509
+ `<div class="fact"><b>${esc(k)}</b>${esc(v)}</div>`).join("");
510
+ }
511
+
512
+ function renderModels(m){
513
+ const d = t(), host = $("#models");
514
+ const rows = (m && m.models) || [];
515
+ $("#mcounts").textContent = d.mcounts((m&&m.counts&&m.counts.models)||0,
516
+ (m&&m.counts&&m.counts.entries)||0);
517
+ $("#t_mlegend").innerHTML = d.mlegend;
518
+ if(!rows.length){ host.innerHTML = `<div class="note">${d.mempty}</div>`; return; }
519
+ const max = Math.max(...rows.map(r=>r.best), 1);
520
+ host.innerHTML = rows.slice(0,14).map(r=>`
521
+ <div class="mrow">
522
+ <div class="mname" title="${esc(r.model)}">${esc(r.model)}</div>
523
+ <div class="mbar"><i style="width:${(r.best/max*100).toFixed(1)}%"></i>
524
+ <u style="left:${(r.mean/max*100).toFixed(1)}%"></u></div>
525
+ <div class="mnum">${r.best.toFixed(1)} · ${r.n}</div>
526
+ </div>`).join("");
527
+ }
528
+
529
+ async function loadModels(){
530
+ try{ renderModels(await (await fetch(`/api/models?season=${SEASON}`)).json()); }
531
+ catch(e){ renderModels(null); }
532
+ }
533
+
534
  function renderPager(total,pages){
535
  const host = $("#pager"), d = t();
536
  if(pages<=1){ host.innerHTML=""; return; }
 
631
  const j = await r.json();
632
  if(!r.ok){ m.className="msg bad"; m.textContent = j.detail || ("HTTP "+r.status); }
633
  else{ m.className="msg ok"; m.innerHTML = d.ok(j.candidate_id, j.queue_position);
634
+ $("#f_formula").value=""; $("#f_cif").value=""; load(); loadModels(); }
635
  }catch(e){ m.className="msg bad"; m.textContent=String(e); }
636
  $("#go").disabled = false;
637
  };
 
651
  }catch(e){}
652
  setLang("ko");
653
  await load();
654
+ loadModels();
655
  setInterval(load, 20000);
656
+ setInterval(loadModels, 600000);
657
  })();
658
  </script>
659
  </body>
seasons.py CHANGED
@@ -3,7 +3,7 @@
3
 
4
  시즌마다 대상 물질군·게이트·배점이 다르다. 한 프로세스가 모든 시즌을 서빙하므로
5
  설정은 **환경변수가 아니라 인자**로 흐른다 - 환경변수로 두면 시즌 하나의 설정이
6
- 다른 시즌 요청에 새어 들어간다 (ODC 에서 실제로 그럴 뻔했다).
7
  """
8
 
9
  SEASONS = [
 
3
 
4
  시즌마다 대상 물질군·게이트·배점이 다르다. 한 프로세스가 모든 시즌을 서빙하므로
5
  설정은 **환경변수가 아니라 인자**로 흐른다 - 환경변수로 두면 시즌 하나의 설정이
6
+ 다른 시즌 요청에 새어 들어간다.
7
  """
8
 
9
  SEASONS = [
store.py CHANGED
@@ -131,12 +131,8 @@ class Cached:
131
  Staleness is bounded and harmless here: the worst case is a leaderboard a few seconds
132
  behind, and the page polls anyway.
133
 
134
- **Single flight.** The first version had no lock, so every concurrent request that
135
- arrived after the TTL expired saw a miss and every one of them re-ran produce(). With
136
- a few thousand entries that is a full paginated tree listing each, and on a 2-vCPU
137
- container the request threadpool fills with blocked Hub I/O - at which point even the
138
- static index page queues behind it and the site stops answering. One refresher at a
139
- time; everyone else is served the value we already have.
140
 
141
  **Stale beats blocking, and stale beats an error.** A leaderboard a minute old is a
142
  working page. A timeout is not.
 
131
  Staleness is bounded and harmless here: the worst case is a leaderboard a few seconds
132
  behind, and the page polls anyway.
133
 
134
+ **Single flight.** One refresher at a time; everyone else is served the value already
135
+ held, so an expiry does not send every concurrent request to the Hub at once.
 
 
 
 
136
 
137
  **Stale beats blocking, and stale beats an error.** A leaderboard a minute old is a
138
  working page. A timeout is not.