chart-ab-eval / app.py
tkdrnjs0621's picture
v5 (launch): save-before-complete ordering, retry guidance on save failure
dd76518 verified
Raw
History Blame Contribute Delete
11.3 kB
"""chartbench 휴먼 이밸: Qwen vs Opus blind A/B (4μΆ•).
- 접속: 곡유 링크의 ?key= κ°€ ACCESS_KEY 와 μΌμΉ˜ν•΄μ•Ό 평가 κ°€λŠ₯ (SpaceλŠ” publicμ΄μ§€λ§Œ ν‚€ μ—†μœΌλ©΄ μ§„μž… λΆˆκ°€)
- ν• λ‹Ή: λ―Έμ™„λ£Œ μ•„μ΄ν…œ 쀑 졜근 10λΆ„ λ‚΄ νƒ€μΈμ—κ²Œ ν• λ‹Ήλ˜μ§€ μ•Šμ€ 것을 랜덀 μ§€κΈ‰. 1λͺ… 제좜 = κ·Έ μ•„μ΄ν…œ μ™„λ£Œ.
- μ €μž₯: 제좜 1건 = results repo 에 JSON 1파일 (rater / ν• λ‹ΉΒ·μ œμΆœ μ‹œκ° / μ†Œμš”μ΄ˆ 포함). μž¬μ‹œμž‘ μ‹œ repo 둜 μ™„λ£Œ λͺ©λ‘ 볡원.
- 같은 이름 = 같은 ν‰κ°€μžλ‘œ 집계 (뢄석 μ‹œ 이름 κΈ°μ€€ dedup).
"""
import json, os, time, uuid, random, threading
from datetime import datetime, timezone, timedelta
from pathlib import Path
import gradio as gr
from huggingface_hub import HfApi
ACCESS_KEY = os.environ.get("ACCESS_KEY", "devkey")
RESULTS_REPO = os.environ.get("RESULTS_REPO", "")
HF_TOKEN = os.environ.get("HF_TOKEN", "")
RESERVE_TTL = 600
KST = timezone(timedelta(hours=9))
ROOT = Path(__file__).parent
ITEMS = [json.loads(l) for l in (ROOT/"items.jsonl").open()]
BY_ID = {it["item_id"]: it for it in ITEMS}
HINT = '<div class="axis-hint">{}</div>'
AXES = [
("query_following",
"**1. 쿼리 μš”κ΅¬μ‚¬ν•­ 반영** β€” μ–΄λŠ μ°¨νŠΈκ°€ 질문이 μš”κ΅¬ν•œ λ°”λ₯Ό 더 잘 λ°˜μ˜ν–ˆλ‚˜μš”?"),
("chart_quality",
"**2. κ°€λ…μ„±Β·μ°¨νŠΈ ν’ˆμ§ˆ** β€” μ–΄λŠ μ°¨νŠΈκ°€ 더 읽기 μ’‹κ³  μ μ ˆν•œ ν˜•νƒœμΈκ°€μš”?"),
("data_fidelity",
"**3. Data λ°˜μ˜λ„** β€” μ–΄λŠ μ°¨νŠΈκ°€ 기반 데이터λ₯Ό 더 μ •ν™•νžˆ λ°˜μ˜ν–ˆλ‚˜μš”?"
+ HINT.format("두 차트의 μˆ˜μΉ˜κ°€ μœ μ‚¬ν•΄ 보이면 'λΉ„μŠ·ν•¨'을 λˆŒλŸ¬μ£Όμ„Έμš”. 크게 λ‹€λ₯΄λ©΄ ν•˜λ‹¨ ν† κΈ€μ˜ 원본 ν‘œλ₯Ό 확인해 νŒλ‹¨ν•˜μ‹œκ³ , ν‘œ μžμ²΄κ°€ μ΄μƒν•˜κ±°λ‚˜ λ‘˜ λ‹€ 잘λͺ» λ°˜μ˜ν–ˆμœΌλ©΄ 'λΉ„μŠ·ν•¨'을 λˆŒλŸ¬μ£Όμ„Έμš”.")),
("overall",
"**4. μ „λ°˜μ  μ„ ν˜Έ** β€” μ’…ν•©μ μœΌλ‘œ μ–΄λŠ 차트λ₯Ό μ„ ν˜Έν•˜μ‹œλ‚˜μš”?"),
]
CHOICES = [("차트 A", "A"), ("차트 B", "B"), ("λΉ„μŠ·ν•¨ / νŒλ‹¨ 어렀움", "Tie")]
GUIDE = """
### μ•ˆλ‚΄
1. **이름을 μž…λ ₯ν•˜κ³  μ‹œμž‘**을 λˆ„λ₯΄λ©΄ μ„ ν˜Έ νŒλ‹¨ λ¬Έμ œκ°€ λ¬΄μž‘μœ„λ‘œ μ œμ‹œλ©λ‹ˆλ‹€. *(읡λͺ…μœΌλ‘œ μž‘μ„±ν•˜μ…”λ„ λ©λ‹ˆλ‹€)*
2. 각 λ¬Έμ œλŠ” **질문(쿼리) 1개 + 차트 2개(A/B)** 둜 κ΅¬μ„±λ˜λ©°, 차트의 기반 데이터(원본 ν‘œ)λŠ” ν•˜λ‹¨μ— μ ‘ν˜€ μžˆμŠ΅λ‹ˆλ‹€.
3. νŒλ‹¨ ν•­λͺ©μ€ **4κ°€μ§€** β€” 쿼리 μš”κ΅¬μ‚¬ν•­ 반영 Β· 가독성 Β· Data λ°˜μ˜λ„ Β· μ „λ°˜μ  μ„ ν˜Έ β€” 각각 **A / B / λΉ„μŠ·ν•¨** 쀑 μ„ νƒν•©λ‹ˆλ‹€.
4. μ œμΆœν•˜λ©΄ κ·Έ λ¬Έμ œλŠ” μ™„λ£Œλ˜κ³  λ‹€μŒ λ¬Έμ œκ°€ λ‚˜μ˜΅λ‹ˆλ‹€. **인당 10개 이상 ν•΄μ£Όμ‹œλ©΄ κ°μ‚¬ν•˜κ² μŠ΅λ‹ˆλ‹€.** *(λ°”μ˜μ‹œλ‹€λ©΄ 더 적게 해주셔도 큰 도움이 λ©λ‹ˆλ‹€!)* 쀑간에 κ·Έλƒ₯ 창을 닫아도 λ©λ‹ˆλ‹€.
5. κ°€λ³κ²Œ 보고 **λŠκ»΄μ§€λŠ” λŒ€λ‘œ** μ„ νƒν•˜μ‹œλ©΄ λ©λ‹ˆλ‹€. Data λ°˜μ˜λ„λŠ” ν‘œλ₯Ό 일일이 λŒ€μ‘°ν•˜κΈ°λ³΄λ‹€, 두 차트의 μˆ˜μΉ˜κ°€ 크게 λ‹€λ₯Ό λ•Œλ§Œ ν‘œλ₯Ό μ°Έκ³ ν•˜λŠ” 정도면 μΆ©λΆ„ν•©λ‹ˆλ‹€.
"""
CSS = """
.guide-box {font-size: 1.15rem; line-height: 1.75;}
.guide-box li {margin-bottom: 6px;}
.axis-hint {color: var(--body-text-color-subdued); font-size: 0.92em; margin-top: 2px;}
.query-card {font-size: 1.25rem; line-height: 1.55; background: var(--block-background-fill);
border: 1px solid var(--border-color-primary); border-left: 6px solid #4f7cff;
border-radius: 10px; padding: 14px 18px; margin: 4px 0 10px;}
.axis-block .wrap {gap: 4px;}
footer {display: none !important;}
"""
api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
_lock = threading.Lock()
_completed: set[str] = set()
_reserved: dict[str, float] = {}
_rater_counts: dict[str, int] = {}
def _load_completed():
"""κ²°κ³Ό repo μŠ€λƒ…μƒ·μ—μ„œ μ™„λ£Œ μ•„μ΄ν…œκ³Ό ν‰κ°€μžλ³„ λˆ„μ  수λ₯Ό λ³΅μ›ν•œλ‹€."""
if not (api and RESULTS_REPO):
return
try:
from huggingface_hub import snapshot_download
local = snapshot_download(RESULTS_REPO, repo_type="dataset", token=HF_TOKEN,
allow_patterns=["results/*.json"])
for p in Path(local).glob("results/*.json"):
try:
rec = json.loads(p.read_text())
except Exception:
continue
if rec.get("item_id"):
_completed.add(rec["item_id"])
r = (rec.get("rater") or "").strip()
if r:
_rater_counts[r] = _rater_counts.get(r, 0) + 1
except Exception as e:
print("results repo load failed:", e)
_load_completed()
print(f"items={len(ITEMS)} completed={len(_completed)}")
def _now_iso() -> str:
return datetime.now(KST).isoformat(timespec="seconds")
def _pick_item() -> str | None:
now = time.time()
with _lock:
for k, t in list(_reserved.items()):
if now - t > RESERVE_TTL:
del _reserved[k]
pool = [it["item_id"] for it in ITEMS
if it["item_id"] not in _completed and it["item_id"] not in _reserved]
if not pool:
pool = [it["item_id"] for it in ITEMS if it["item_id"] not in _completed]
if not pool:
return None
item_id = random.choice(pool)
_reserved[item_id] = now
return item_id
def _save(rater: str, item_id: str, answers: dict, assigned_at: str, assigned_ts: float):
rec = {"rater": rater, "item_id": item_id, "answers": answers,
"assigned_at": assigned_at, "submitted_at": _now_iso(),
"elapsed_s": round(time.time() - assigned_ts, 1) if assigned_ts else None,
"id": uuid.uuid4().hex[:8]}
# μ €μž₯이 μ„±κ³΅ν•œ λ’€μ—λ§Œ μ™„λ£Œ μ²˜λ¦¬ν•œλ‹€: μ—…λ‘œλ“œ μ‹€νŒ¨ μ‹œ μ˜ˆμ™Έλ₯Ό 올렀 μ‚¬μš©μžμ—κ²Œ
# μž¬μ œμΆœμ„ μ•ˆλ‚΄ν•˜κ³ , μ•„μ΄ν…œμ€ λ―Έμ™„λ£Œλ‘œ λ‚¨λŠ”λ‹€ (응닡 μœ μ‹€ λ°©μ§€).
if api and RESULTS_REPO:
num = item_id.split("_")[1]
api.upload_file(path_or_fileobj=json.dumps(rec, ensure_ascii=False).encode(),
path_in_repo=f"results/r_{num}_{rec['id']}.json",
repo_id=RESULTS_REPO, repo_type="dataset",
commit_message=f"rating {item_id} by {rater}")
else:
with (ROOT/"local_results.jsonl").open("a") as f:
f.write(json.dumps(rec, ensure_ascii=False)+"\n")
with _lock:
_completed.add(item_id)
_reserved.pop(item_id, None)
_rater_counts[rater] = _rater_counts.get(rater, 0) + 1
def _progress(my_count: int) -> str:
return f"전체 μ§„ν–‰ **{len(_completed)} / {len(ITEMS)}** &nbsp;Β·&nbsp; λ‚΄κ°€ ν‰κ°€ν•œ 수 **{my_count}**"
def _render_item(item_id: str):
it = BY_ID[item_id]
return (f'<div class="query-card"><b>질문</b><br>{it["query"]}</div>',
str(ROOT/"images"/f"{item_id}_A.png"), str(ROOT/"images"/f"{item_id}_B.png"),
it["table_markdown"])
N_START_OUT = 12 # gate, panel, query, imgA, imgB, table, progress, rater, item, assigned_at, assigned_ts, my_count
def start(rater, request: gr.Request):
if (request.query_params.get("key") or "") != ACCESS_KEY:
raise gr.Error("μ ‘κ·Ό ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€. κ³΅μœ λ°›μ€ 링크둜 μ ‘μ†ν•˜μ„Έμš”.")
rater = (rater or "").strip()
if not rater:
gr.Warning("이름(λ˜λŠ” 읡λͺ… μ½”λ“œ)을 μž…λ ₯ν•΄μ£Όμ„Έμš”.")
return tuple(gr.skip() for _ in range(N_START_OUT))
my_count = _rater_counts.get(rater, 0)
item_id = _pick_item()
if item_id is None:
gr.Info("λͺ¨λ“  문제 평가가 λλ‚¬μŠ΅λ‹ˆλ‹€. κ°μ‚¬ν•©λ‹ˆλ‹€!")
return (gr.update(visible=True), gr.update(visible=False), "", None, None, "",
"πŸŽ‰ λͺ¨λ“  λ¬Έμ œκ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!", rater, "", "", 0.0, my_count)
q, a, b, t = _render_item(item_id)
return (gr.update(visible=False), gr.update(visible=True), q, a, b, t,
_progress(my_count), rater, item_id, _now_iso(), time.time(), my_count)
N_SUB_OUT = 11 # query, imgA, imgB, table, progress, item, assigned_at, assigned_ts, my_count, + 4 radios -> actually computed below
def submit(rater, item_id, assigned_at, assigned_ts, my_count, c1, c2, c3, c4, request: gr.Request):
choices = (c1, c2, c3, c4)
n_out = 9 + len(AXES)
if (request.query_params.get("key") or "") != ACCESS_KEY:
raise gr.Error("μ ‘κ·Ό ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€.")
if not item_id:
gr.Warning("ν• λ‹Ήλœ λ¬Έμ œκ°€ μ—†μŠ΅λ‹ˆλ‹€. μƒˆλ‘œκ³ μΉ¨ ν›„ λ‹€μ‹œ μ‹œμž‘ν•΄μ£Όμ„Έμš”.")
return tuple(gr.skip() for _ in range(n_out))
missing = [f"{i+1}번" for i, c in enumerate(choices) if c is None]
if missing:
gr.Warning("λͺ¨λ“  ν•­λͺ©μ„ μ„ νƒν•΄μ£Όμ„Έμš”. 미선택: " + ", ".join(missing))
return tuple(gr.skip() for _ in range(n_out))
try:
_save(rater, item_id, {k: c for (k, _), c in zip(AXES, choices)}, assigned_at, assigned_ts)
except Exception as e:
print("save failed:", type(e).__name__, e)
gr.Warning("μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€ (λ„€νŠΈμ›Œν¬ 문제일 수 μžˆμŠ΅λ‹ˆλ‹€). μž μ‹œ ν›„ 'μ œμΆœν•˜κ³  λ‹€μŒ'을 λ‹€μ‹œ λˆŒλŸ¬μ£Όμ„Έμš” β€” 선택은 μœ μ§€λ©λ‹ˆλ‹€.")
with _lock:
_reserved[item_id] = time.time() # 이 μ‚¬μš©μž λͺ«μœΌλ‘œ μ˜ˆμ•½ μ—°μž₯
return tuple(gr.skip() for _ in range(n_out))
my_count = (my_count or 0) + 1
nxt = _pick_item()
if nxt is None:
return ("πŸŽ‰ λͺ¨λ“  λ¬Έμ œκ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ°Έμ—¬ν•΄μ£Όμ…”μ„œ κ°μ‚¬ν•©λ‹ˆλ‹€!", None, None, "",
_progress(my_count), "", "", 0.0, my_count, *[None]*len(AXES))
q, a, b, t = _render_item(nxt)
return (q, a, b, t, _progress(my_count), nxt, _now_iso(), time.time(), my_count, *[None]*len(AXES))
with gr.Blocks(title="차트 μ„ ν˜Έ 평가") as demo:
gr.Markdown("# πŸ“Š 차트 생성 μ„ ν˜Έ 평가 (Blind A/B)\n같은 μ§ˆλ¬ΈΒ·κ°™μ€ λ°μ΄ν„°λ‘œ 두 μ‹œμŠ€ν…œμ΄ λ§Œλ“  μ°¨νŠΈμž…λ‹ˆλ‹€. ν•­λͺ©λ³„λ‘œ **더 λ‚˜μ€ μͺ½**을 κ³¨λΌμ£Όμ„Έμš”. A/B λ°°μΉ˜λŠ” λ¬Έμ œλ§ˆλ‹€ λ¬΄μž‘μœ„μž…λ‹ˆλ‹€.")
with gr.Group(visible=True) as gate:
rater_in = gr.Textbox(label="이름 (λ˜λŠ” 읡λͺ… μ½”λ“œ)", placeholder="예: λ°•OO")
start_btn = gr.Button("평가 μ‹œμž‘", variant="primary")
gr.Markdown(GUIDE, elem_classes="guide-box")
with gr.Group(visible=False) as panel:
prog = gr.Markdown()
qhtml = gr.HTML()
with gr.Row():
img_a = gr.Image(label="차트 A", type="filepath", height=440)
img_b = gr.Image(label="차트 B", type="filepath", height=440)
radios = []
for _, axis_md in AXES:
gr.Markdown(axis_md)
radios.append(gr.Radio(CHOICES, show_label=False, elem_classes="axis-block"))
sub_btn = gr.Button("μ œμΆœν•˜κ³  λ‹€μŒ β†’", variant="primary", size="lg")
with gr.Accordion("원본 ν‘œ 펼쳐보기 (Data λ°˜μ˜λ„ 참고용)", open=False):
tbl = gr.Markdown()
rater_st = gr.State(""); item_st = gr.State(""); at_st = gr.State(""); ts_st = gr.State(0.0); cnt_st = gr.State(0)
start_btn.click(start, [rater_in],
[gate, panel, qhtml, img_a, img_b, tbl, prog, rater_st, item_st, at_st, ts_st, cnt_st])
sub_btn.click(submit, [rater_st, item_st, at_st, ts_st, cnt_st, *radios],
[qhtml, img_a, img_b, tbl, prog, item_st, at_st, ts_st, cnt_st, *radios])
if __name__ == "__main__":
demo.launch(css=CSS)