File size: 11,322 Bytes
d7686af
 
2c74d58
 
 
 
d7686af
 
2c74d58
d7686af
 
 
 
 
 
2c74d58
d7686af
2c74d58
 
d7686af
 
 
 
2c74d58
cb9016e
d7686af
cb9016e
d51e344
cb9016e
d51e344
cb9016e
 
 
 
 
d7686af
2c74d58
 
 
 
a5a00fb
2c74d58
 
7c1b981
2c74d58
 
 
 
cb9016e
 
 
2c74d58
 
 
 
 
 
d7686af
 
 
 
 
 
80d473a
 
d7686af
80d473a
d7686af
 
 
80d473a
 
 
 
 
 
 
 
 
 
 
 
 
d7686af
 
 
 
 
 
2c74d58
 
 
d7686af
 
 
 
 
 
 
 
 
2c74d58
d7686af
 
 
 
 
 
2c74d58
d7686af
2c74d58
 
 
dd76518
 
d7686af
 
 
2c74d58
 
d7686af
 
2c74d58
 
dd76518
 
 
 
d7686af
2c74d58
 
d7686af
 
 
2c74d58
 
 
 
80d473a
d7686af
 
 
 
 
 
2c74d58
 
80d473a
d7686af
 
2c74d58
d7686af
80d473a
d7686af
 
80d473a
2c74d58
 
d7686af
2c74d58
3c35862
2c74d58
d7686af
 
 
2c74d58
cb62a14
2c74d58
cb62a14
2c74d58
cb62a14
dd76518
 
 
 
 
 
 
 
2c74d58
d7686af
 
2c74d58
 
d7686af
2c74d58
d7686af
2c74d58
 
d7686af
2c74d58
d7686af
cb9016e
d7686af
 
2c74d58
d7686af
2c74d58
 
 
 
 
 
 
 
 
 
 
80d473a
2c74d58
 
d7686af
3c35862
2c74d58
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""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)