rafmacalaba commited on
Commit
53ea208
·
verified ·
1 Parent(s): 8a053dd

annotation review app (per-user queues, Hub-backed rulings, static-safe direct commit)

Browse files
.dockerignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
Dockerfile ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ FROM python:3.14-slim
2
+ RUN pip install --no-cache-dir huggingface_hub
3
+ COPY . /app/human_labeling
4
+ EXPOSE 7860
5
+ CMD ["python", "human_labeling/app.py", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,98 @@
1
- ---
2
- title: Data Use Annotate
3
- emoji: 🔥
4
- colorFrom: purple
5
- colorTo: pink
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # human_labeling
2
+
3
+ Fork of `analysis/v24_sample_review/audit_deck.html` as a hostable
4
+ annotation pipeline. Same blind KEEP/DROP interaction, but the queue
5
+ comes from the Hub instead of an 11MB embedded `ROWS` array.
6
+
7
+ - Source: `rafmacalaba/datause-ner` ([viewer](https://huggingface.co/datasets/rafmacalaba/datause-ner/viewer/probe_splits))
8
+ for passages; **scores are singlepass only** — the infer head in
9
+ `rafmacalaba/gliner-datause-catchall-singlepass`
10
+ (`sigmoid(head([start; end; mean; ±64 window]))` on inference-native features).
11
+ No v3/encode-head scores anywhere in this tool.
12
+ - Default queue: `probe_candidates/pool` — the only config with an
13
+ embedded `passage`. `probe_splits` train/val/holdout rows have **no
14
+ passage** (surface + word offsets only), so they annotate surface-only
15
+ unless joined to a chunk corpus (see caveat below).
16
+ - Annotations land in `human_labeling/annotations/rulings.jsonl`
17
+ (one JSON object per ruling). On static hosting (github.io) there is
18
+ no writer: the app exports a JSON file the annotator drops into that
19
+ folder (PR). On Spaces the bundled `app.py` accepts `POST
20
+ /api/rulings` and appends server-side.
21
+
22
+ ## Quick start (local)
23
+
24
+ ```bash
25
+ uv run python human_labeling/build_gliner_queue.py # Luna spans + singlepass scores
26
+ uv run python human_labeling/app.py # serves index.html + /api/queue + POST /api/rulings
27
+ ```
28
+
29
+ Data source: the `gliner` config of rafmacalaba/datause-ner (tokenized
30
+ passages + catch-all entity spans with camp2 Luna traceability). The queue
31
+ maps each Luna verdict onto its span, rescores with the singlepass head
32
+ (probe score) and the GLiNER proposer (extractor score @0.1), and the UI
33
+ reviews keep/drop with both signals. Retired experiments (pool, passage
34
+ grouping, gliner2/human473 queues) live only in git history.
35
+
36
+ ## Hosting
37
+
38
+ - **github.io (static):** publish `human_labeling/` as-is. `index.html`
39
+ fetches `./queue_gliner.json` (built by `build_gliner_queue.py`, committed). Rulings
40
+ persist in `localStorage`; Export downloads
41
+ `rulings-<annotator>.json`, which is committed under `annotations/`
42
+ and reconciled with `merge.py`.
43
+ - **Hugging Face Spaces (SDK: Docker, port 7860):** same folder plus
44
+ `app.py`. The frontend feature-detects `POST /api/rulings`: if it
45
+ 200s, rulings stream to `annotations/rulings.jsonl` on disk; if not,
46
+ it falls back to export. Dockerfile provided.
47
+
48
+ ## Files
49
+
50
+ | file | purpose |
51
+ |---|---|
52
+ | `index.html` | annotation UI (passage view, per-mention ruling, luna×model filter) |
53
+ | `build_gliner_queue.py` | builds + scores `queue_gliner.json` from the gliner config (singlepass probe + extractor on MPS) |
54
+ | `queue_gliner.json` | served queue: 225 passages / 522 Luna-labeled spans, round-robin over origins, multi-mention first |
55
+ | `probe_labels.py` | shared decision rule + per-origin singlepass thresholds |
56
+ | `app.py` | stdlib server: static + `GET /api/queue` + `POST /api/rulings` |
57
+ | `merge.py` | `annotations/rulings*.jsonl` → `annotations/adjudicated.jsonl` (majority per key) |
58
+ | `annotations/` | ruling storage; every file is JSONL, one ruling per line |
59
+ | `Dockerfile` | Spaces deploy (Docker SDK) |
60
+ | `build_gliner2_queue.py`, `build_pool_queue.py`, `rescore_singlepass.py`, `build_passage_queue.py`, `build_gold_queue.py` | retired experiments (kept for reference; not served) |
61
+
62
+ ## Caveat: `probe_splits` has no context
63
+
64
+ `probe_train/val/holdout.jsonl` rows are
65
+ `key, origin, corpus_id, w_start, w_end, surface, label, source,
66
+ head_score, split` — no passage text. The underlying chunk text lives
67
+ in per-corpus repos (`rafmacalaba/fcv-extractions-meta[-tiered]`), and
68
+ `general_prwp` (the majority origin) has no published chunk source at
69
+ all (same gap as `demo/server.py: HOLDOUT_META_SOURCES`). So:
70
+
71
+ - `build_pool_queue.py --config probe_candidates` (default): full passage, blind ruling quality = audit_deck.
72
+ - `build_pool_queue.py --config probe_splits`: surface-only triage; items are
73
+ flagged `"ctx_missing": true` and the UI shows the surface + scores
74
+ without a context card. Fine for obvious keeps/drops, not for adjudication.
75
+
76
+ ## Bands (decision labels)
77
+
78
+ Every queue item carries `band`, tagged from the singlepass score with the
79
+ bundle's per-origin best-F1 thresholds (`probe_labels.py`):
80
+
81
+ | band | rule |
82
+ |---|---|
83
+ | `keep` | `head_score >= per-origin threshold` — model-flagged keep, quick confirm |
84
+ | `confusion` | `DROP_FLOOR < head_score < threshold` — the review zone (was "mid") |
85
+ | `drop` | `head_score <= 0.05` — confident reject, quick confirm |
86
+ | `unscored` | score missing (out-of-grid span) |
87
+
88
+ Per-origin thresholds (published `thresholds.json`): `fcv_pads_east_africa`
89
+ 0.5 · `general_prwp` 0.4 · `jad_paddy_docs` 0.1 · `jdc_operational` 0.6 ·
90
+ `refugee_pads` 0.5 · `reliefweb` 0.4; fallback `global_best` 0.5.
91
+
92
+ ## Ruling schema
93
+
94
+ ```json
95
+ {"key": "sample:fcv_pads_east_africa:002082:7:1:0", "ruling": "DATA_MENTION",
96
+ "annotator": "rm", "ts": "2026-09-08T12:00:00Z", "note": "",
97
+ "head_score": 0.61, "origin": "fcv_pads_east_africa", "queue": "probe_candidates/pool"}
98
+ `ruling` is `DATA_MENTION` (keep) or `NON_MENTION` (drop) — same labels as the deck export.
__pycache__/probe_labels.cpython-314.pyc ADDED
Binary file (2.95 kB). View file
 
__pycache__/queue.cpython-314.pyc ADDED
Binary file (8.32 kB). View file
 
annotations/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ .gitkeep
annotations/rulings.jsonl ADDED
File without changes
app.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Static server + ruling sink for human_labeling.
3
+
4
+ Serves index.html / queue.json and appends POSTed rulings to
5
+ annotations/rulings.jsonl. Stdlib only. The frontend feature-detects
6
+ POST /api/rulings: static hosts (github.io) 404 it and fall back to
7
+ file export; Spaces (Dockerfile) runs this file so writes land on disk.
8
+
9
+ uv run python human_labeling/app.py [--port 7860]
10
+ """
11
+
12
+ import argparse
13
+ import datetime
14
+ import http.server
15
+ import json
16
+ import socketserver
17
+ from pathlib import Path
18
+ HERE = Path(__file__).resolve().parent
19
+ ANNOT = HERE / "annotations"
20
+ RULINGS = ANNOT / "rulings.jsonl"
21
+ ANN_REPO = "rafmacalaba/data-use-annotations"
22
+ _hub_lines = None
23
+
24
+ class Handler(http.server.SimpleHTTPRequestHandler):
25
+ def __init__(self, *a, **kw):
26
+ super().__init__(*a, directory=str(HERE), **kw)
27
+
28
+ def _json(self, obj, status=200):
29
+ body = json.dumps(obj).encode()
30
+ self.send_response(status)
31
+ self.send_header("Content-Type", "application/json")
32
+ self.send_header("Content-Length", str(len(body)))
33
+ self.end_headers()
34
+ self.wfile.write(body)
35
+
36
+ def do_GET(self):
37
+ if self.path == "/api/queue":
38
+ items = []
39
+ for name in ("queue_gliner.json",):
40
+ p = HERE / name
41
+ if p.exists():
42
+ items.extend(json.loads(r) for r in p.read_text().splitlines() if r.strip())
43
+ return self._json({"items": items})
44
+ if self.path == "/api/health":
45
+ return self._json({"ok": True, "rulings": _count()})
46
+ return super().do_GET()
47
+
48
+ def do_POST(self):
49
+ if self.path != "/api/rulings":
50
+ return self._json({"error": "not found"}, 404)
51
+ try:
52
+ n = int(self.headers.get("Content-Length", 0))
53
+ except ValueError:
54
+ return self._json({"error": "bad length"}, 400)
55
+ try:
56
+ ruling = json.loads(self.rfile.read(n) or b"{}")
57
+ except json.JSONDecodeError:
58
+ return self._json({"error": "bad json"}, 400)
59
+ if not ruling.get("key") or ruling.get("ruling") not in ("DATA_MENTION", "NON_MENTION"):
60
+ return self._json({"error": "need key + ruling"}, 400)
61
+ if not (ruling.get("annotator") or "").strip():
62
+ return self._json({"error": "need annotator name"}, 400)
63
+ ruling["annotator"] = ruling["annotator"].strip()
64
+ ruling.setdefault("ts", datetime.datetime.now(datetime.timezone.utc).isoformat())
65
+ ANNOT.mkdir(exist_ok=True)
66
+ with open(RULINGS, "a") as f:
67
+ f.write(json.dumps(ruling) + "\n")
68
+ pushed = _push_hub(ruling)
69
+ return self._json({"ok": True, "n": _count(), "pushed": pushed})
70
+
71
+
72
+ def _count() -> int:
73
+ if not RULINGS.exists():
74
+ return 0
75
+ with open(RULINGS) as f:
76
+ return sum(1 for line in f if line.strip())
77
+
78
+
79
+ def main() -> None:
80
+ ap = argparse.ArgumentParser()
81
+ ap.add_argument("--port", type=int, default=7860)
82
+ a = ap.parse_args()
83
+ with socketserver.TCPServer(("", a.port), Handler) as httpd:
84
+ print(f"human_labeling on :{a.port} (rulings -> {RULINGS})")
85
+ httpd.serve_forever()
86
+ def _push_hub(ruling) -> bool:
87
+ """Merge one ruling into the annotator's public Hub file
88
+ (rulings/<annotator>.jsonl). Last write per (queue, key) wins.
89
+ Local file stays source of truth; Hub failures never fail."""
90
+ global _hub_lines
91
+ try:
92
+ from huggingface_hub import HfApi, hf_hub_download
93
+ api = HfApi()
94
+ who = "".join(c if c.isalnum() or c in "-_" else "_" for c in ruling["annotator"])
95
+ path = f"rulings/{who}.jsonl"
96
+ if _hub_lines is None:
97
+ _hub_lines = {}
98
+ if who not in _hub_lines:
99
+ try:
100
+ p = hf_hub_download(ANN_REPO, path, repo_type="dataset")
101
+ with open(p) as f:
102
+ _hub_lines[who] = [json.loads(l) for l in f if l.strip()]
103
+ except Exception:
104
+ _hub_lines[who] = []
105
+ rows = [r for r in _hub_lines[who]
106
+ if not (r.get("queue") == ruling.get("queue")
107
+ and r.get("key") == ruling.get("key"))]
108
+ rows.append(ruling)
109
+ _hub_lines[who] = rows
110
+ api.upload_file(
111
+ path_or_fileobj="\n".join(json.dumps(r) for r in rows).encode(),
112
+ path_in_repo=path, repo_id=ANN_REPO, repo_type="dataset",
113
+ commit_message=f"ruling {who}: {ruling.get('key')}={ruling.get('ruling')}",
114
+ )
115
+ return True
116
+ except Exception as exc: # noqa: BLE001
117
+ print(f"hub push failed: {exc}")
118
+ return False
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
build_gliner2_queue.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build the gliner2 adjudication queue: Luna-labeled spans rescored with
3
+ the singlepass bundle (rafmacalaba/gliner-datause-catchall-singlepass).
4
+
5
+ Source: the gliner2 config of rafmacalaba/datause-ner (passage rows with
6
+ spans[]: text, luna_label, char start/end, key). Local mirrors in
7
+ hf_datause_ner/gliner2_*.jsonl are byte-identical to the Hub (verified:
8
+ 29,346/29,346 keys) and used as the fetch source.
9
+
10
+ Per mention the queue carries:
11
+ luna camp2 verdict, 1=keep / 0=drop
12
+ head_score probe_score from the singlepass infer head (MPS)
13
+ extractor_score GLiNER proposer score @0.1 for the same grid cell
14
+ (null when the proposer didn't fire on the span)
15
+ band keep/confusion/drop via probe_labels.decide
16
+
17
+ Sampling: round-robin over origins, multi-mention passages first, span
18
+ budget --limit (default 520). Overlapping duplicate spans are kept as
19
+ separate mentions (the UI renderer suppresses in-text doubles).
20
+
21
+ uv run python human_labeling/build_gliner2_queue.py [--limit 520] [--batch 8]
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from collections import defaultdict
28
+ from pathlib import Path
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ REPO = HERE.parent
32
+ sys.path.insert(0, str(REPO))
33
+
34
+ MIRROR = REPO / "hf_datause_ner"
35
+ OUT = HERE / "queue_gliner2.json"
36
+
37
+
38
+ def load_passages() -> list[dict]:
39
+ rows = []
40
+ for split in ("train", "val", "holdout"):
41
+ for line in (MIRROR / f"gliner2_{split}.jsonl").read_text().splitlines():
42
+ if line.strip():
43
+ r = json.loads(line)
44
+ r["split"] = split
45
+ rows.append(r)
46
+ return rows
47
+
48
+
49
+ def main() -> None:
50
+ ap = argparse.ArgumentParser()
51
+ ap.add_argument("--model", default="rafmacalaba/gliner-datause-catchall-singlepass")
52
+ ap.add_argument("--limit", type=int, default=520, help="span budget")
53
+ ap.add_argument("--batch", type=int, default=8)
54
+ a = ap.parse_args()
55
+
56
+ import torch
57
+ from training.singlepass_infer import default_device, load_bundle
58
+ from training.probe_features_infer import char_to_infer_word
59
+ from probe_labels import decide
60
+
61
+ passages = load_passages()
62
+ # round-robin origins, multi-mention passages first within each origin
63
+ by_origin: dict[str, list[dict]] = defaultdict(list)
64
+ for p in passages:
65
+ by_origin[p["origin"]].append(p)
66
+ ordered: list[dict] = []
67
+ origins = sorted(by_origin)
68
+ for o in origins:
69
+ by_origin[o].sort(key=lambda p: -min(len(p["spans"]), 2))
70
+ i = 0
71
+ while any(by_origin[o] for o in origins):
72
+ o = origins[i % len(origins)]
73
+ if by_origin[o]:
74
+ ordered.append(by_origin[o].pop(0))
75
+ i += 1
76
+
77
+ # take passages until span budget; dedupe exact-duplicate keys in passage
78
+ chosen, n_spans = [], 0
79
+ for p in ordered:
80
+ if n_spans >= a.limit:
81
+ break
82
+ seen, spans = set(), []
83
+ for s in p["spans"]:
84
+ if s["key"] in seen:
85
+ continue
86
+ seen.add(s["key"])
87
+ spans.append(s)
88
+ chosen.append((p, spans))
89
+ n_spans += len(spans)
90
+
91
+ device = default_device()
92
+ print(f"device={device} model={a.model} passages={len(chosen)} spans={n_spans}",
93
+ flush=True)
94
+ model, head, bundle = load_bundle(
95
+ "rafmacalaba/gliner-datause-mentions-catch-all", a.model, device)
96
+ thresholds = bundle.get("thresholds") or {}
97
+ radius = bundle["radius"]
98
+
99
+ import torch.utils.data
100
+ INFER_LABELS = ["DATA_MENTION"]
101
+ texts = [p["input"] for p, _ in chosen]
102
+ prepared = model.prepare_batch(texts, INFER_LABELS)
103
+ collator = model.create_collator()
104
+
105
+ def collate_fn(batch):
106
+ return model.collate_batch(batch, prepared["entity_types"], collator)
107
+
108
+ loader = torch.utils.data.DataLoader(
109
+ prepared["input_x"], batch_size=a.batch, shuffle=False,
110
+ collate_fn=collate_fn)
111
+
112
+ v2o = prepared["valid_to_orig_idx"]
113
+ o2v = {o: v for v, o in enumerate(v2o)}
114
+ n_probe = n_ext = n_skip = 0
115
+ items: list[dict] = []
116
+
117
+ def flush(p, spans, probes, extractors):
118
+ mentions = []
119
+ for s, probe, ext in zip(spans, probes, extractors):
120
+ mentions.append({
121
+ "key": s["key"], "surface": s["text"],
122
+ "start": s["start"], "end": s["end"],
123
+ "luna": s.get("luna_label"),
124
+ "head_score": probe, "extractor_score": ext,
125
+ "band": decide(probe, p["origin"], thresholds),
126
+ })
127
+ bands = {m["band"] for m in mentions}
128
+ pband = ("unscored" if "unscored" in bands else
129
+ "confusion" if "confusion" in bands else
130
+ "mixed" if len(bands) > 1 else bands.pop())
131
+ items.append({
132
+ "queue": "gliner2", "origin": p["origin"], "split": p["split"],
133
+ "ctx": p["input"], "n": len(mentions), "band": pband,
134
+ "mentions": mentions, "scored_by": a.model,
135
+ })
136
+
137
+ row = 0
138
+ with torch.no_grad():
139
+ for batch in loader:
140
+ out = model.run_batch(batch, threshold=0.1, move_to_device=True)
141
+ W = out.words_embedding.detach().float()
142
+ mask = (out.mask.detach().cpu()
143
+ if getattr(out, "mask", None) is not None else None)
144
+ decoded = model.decode_batch(out, batch, threshold=0.1,
145
+ flat_ner=True, multi_label=False)
146
+ B = W.shape[0]
147
+ for bi in range(B):
148
+ vi = row + bi
149
+ oi = v2o[vi]
150
+ p, spans = chosen[oi]
151
+ if o2v.get(oi) is None: # empty passage filtered upstream
152
+ flush(p, spans, [None] * len(spans), [None] * len(spans))
153
+ n_skip += len(spans)
154
+ continue
155
+ w = W[bi].to(device)
156
+ L = int(mask[bi].sum()) if mask is not None else w.shape[0]
157
+ starts = prepared["start_token_map"][vi]
158
+ proposals = [(int(sp.start), int(sp.end), float(sp.score))
159
+ for sp in decoded[bi]]
160
+ probes, extractors = [], []
161
+ for s in spans:
162
+ cs, ce = s["start"], s["end"]
163
+ g0, g1 = char_to_infer_word(starts, cs, ce)
164
+ probe = ext = None
165
+ if g1 < L and g0 < L:
166
+ idx = torch.arange(g0, g1 + 1, device=device)
167
+ parts = [w[g0], w[g1], w[idx].mean(dim=0)]
168
+ if radius > 0:
169
+ w0, w1 = max(0, g0 - radius), min(g1 + radius, L - 1)
170
+ parts.append(w[w0:w1 + 1].mean(dim=0))
171
+ probe = float(torch.sigmoid(
172
+ head(torch.cat(parts).unsqueeze(0))).item())
173
+ n_probe += 1
174
+ hit = [sc for (ps, pe, sc) in proposals
175
+ if ps == g0 and pe == g1]
176
+ if hit:
177
+ ext = hit[0]
178
+ n_ext += 1
179
+ probes.append(probe)
180
+ extractors.append(ext)
181
+ flush(p, spans, probes, extractors)
182
+ row += B
183
+
184
+ OUT.write_text("\n".join(json.dumps(it) for it in items) + "\n")
185
+ from collections import Counter
186
+ bands = Counter(m["band"] for it in items for m in it["mentions"])
187
+ lu = Counter(m["luna"] for it in items for m in it["mentions"])
188
+ print(f"queue: passages={len(items)} spans={sum(it['n'] for it in items)} "
189
+ f"probe_scored={n_probe} extractor_matched={n_ext} "
190
+ f"unscored={n_skip} bands={dict(bands)} luna={dict(lu)} -> {OUT}",
191
+ flush=True)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
build_gliner_queue.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build the annotation queue from the gliner config of
3
+ rafmacalaba/datause-ner, with camp2 Luna verdicts mapped onto every
4
+ entity span and scores from the singlepass bundle
5
+ (rafmacalaba/gliner-datause-catchall-singlepass).
6
+
7
+ gliner rows: tokenized_text + ner (catch-all DATA_MENTION token spans)
8
+ + spans[] traceability (key, luna_label, text, source). Join is positional:
9
+ ner[i] <-> spans[i] (keys are sequential per passage; verified).
10
+
11
+ ctx = ' '.join(tokenized_text); char offsets are computed on that grid, so
12
+ they are exact by construction. Per mention:
13
+ luna camp2 verdict, 1=keep / 0=drop
14
+ head_score probe_score from the singlepass infer head (MPS)
15
+ extractor_score GLiNER proposer score @0.1 matched on the inference grid
16
+ band keep/confusion/drop via probe_labels.decide
17
+
18
+ Sampling: round-robin over origins, multi-mention passages first, span
19
+ budget --limit (default 520).
20
+
21
+ uv run python human_labeling/build_gliner_queue.py [--limit 520] [--batch 8]
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from collections import defaultdict
28
+ from pathlib import Path
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ REPO = HERE.parent
32
+ sys.path.insert(0, str(REPO))
33
+
34
+ MIRROR = REPO / "hf_datause_ner"
35
+ OUT = HERE / "queue_gliner.json"
36
+
37
+
38
+ def token_char_offsets(tokens: list[str]) -> list[int]:
39
+ offs, c = [], 0
40
+ for t in tokens:
41
+ offs.append(c)
42
+ c += len(t) + 1
43
+ return offs
44
+
45
+
46
+ def load_passages() -> list[dict]:
47
+ rows = []
48
+ for split in ("train", "val", "holdout"):
49
+ for line in (MIRROR / f"gliner_{split}.jsonl").read_text().splitlines():
50
+ if line.strip():
51
+ r = json.loads(line)
52
+ r["split"] = split
53
+ rows.append(r)
54
+ return rows
55
+
56
+
57
+ def main() -> None:
58
+ ap = argparse.ArgumentParser()
59
+ ap.add_argument("--model", default="rafmacalaba/gliner-datause-catchall-singlepass")
60
+ ap.add_argument("--limit", type=int, default=520, help="span budget")
61
+ ap.add_argument("--batch", type=int, default=8)
62
+ a = ap.parse_args()
63
+
64
+ import torch
65
+ import torch.utils.data
66
+ from training.singlepass_infer import default_device, load_bundle
67
+ from training.probe_features_infer import char_to_infer_word
68
+ from probe_labels import decide
69
+
70
+ passages = load_passages()
71
+ by_origin: dict[str, list[dict]] = defaultdict(list)
72
+ for p in passages:
73
+ if len(p.get("ner", [])) == len(p.get("spans", [])) and p["ner"]:
74
+ by_origin[p["origin"]].append(p)
75
+ origins = sorted(by_origin)
76
+ for o in origins:
77
+ by_origin[o].sort(key=lambda p: -min(len(p["ner"]), 2))
78
+ ordered: list[dict] = []
79
+ i = 0
80
+ while any(by_origin[o] for o in origins):
81
+ o = origins[i % len(origins)]
82
+ if by_origin[o]:
83
+ ordered.append(by_origin[o].pop(0))
84
+ i += 1
85
+
86
+ chosen, n_spans = [], 0
87
+ for p in ordered:
88
+ if n_spans >= a.limit:
89
+ break
90
+ # dedupe identical spans (upstream extractor proposed the same span
91
+ # twice under separate keys; 173 groups corpus-wide, 22 with
92
+ # CONFLICTING luna verdicts). First key wins; conflicting duplicates
93
+ # flag the survivor as luna_split for the UI.
94
+ first, ner_u, spans_u = {}, [], []
95
+ for i, (ner, s) in enumerate(zip(p["ner"], p["spans"])):
96
+ k = (ner[0], ner[1])
97
+ if k not in first:
98
+ first[k] = len(spans_u)
99
+ ner_u.append(ner)
100
+ spans_u.append(dict(s))
101
+ elif spans_u[first[k]].get("luna_label") != s.get("luna_label"):
102
+ spans_u[first[k]]["luna_split"] = True
103
+ q = dict(p, ner=ner_u, spans=spans_u)
104
+ chosen.append(q)
105
+ n_spans += len(ner_u)
106
+ device = default_device()
107
+ print(f"device={device} model={a.model} passages={len(chosen)} spans={n_spans}",
108
+ flush=True)
109
+ model, head, bundle = load_bundle(
110
+ "rafmacalaba/gliner-datause-mentions-catch-all", a.model, device)
111
+ thresholds = bundle.get("thresholds") or {}
112
+ radius = bundle["radius"]
113
+
114
+ INFER_LABELS = ["DATA_MENTION"]
115
+ texts, char_maps = [], []
116
+ for p in chosen:
117
+ toks = p["tokenized_text"]
118
+ texts.append(" ".join(toks))
119
+ char_maps.append(token_char_offsets(toks))
120
+ prepared = model.prepare_batch(texts, INFER_LABELS)
121
+ collator = model.create_collator()
122
+
123
+ def collate_fn(batch):
124
+ return model.collate_batch(batch, prepared["entity_types"], collator)
125
+
126
+ loader = torch.utils.data.DataLoader(
127
+ prepared["input_x"], batch_size=a.batch, shuffle=False,
128
+ collate_fn=collate_fn)
129
+
130
+ v2o = prepared["valid_to_orig_idx"]
131
+ n_probe = n_ext = n_skip = 0
132
+ items: list[dict] = []
133
+
134
+ def flush(p, ctx, char_offs, probes, extractors):
135
+ mentions = []
136
+ for i, (ner, s, probe, ext) in enumerate(
137
+ zip(p["ner"], p["spans"], probes, extractors)):
138
+ t0, t1 = ner[0], ner[1]
139
+ start = char_offs[t0]
140
+ end = char_offs[t1] + len(p["tokenized_text"][t1])
141
+ mentions.append({
142
+ "key": s["key"], "surface": " ".join(p["tokenized_text"][t0:t1 + 1]),
143
+ "start": start, "end": end,
144
+ "luna": s.get("luna_label"), "luna_split": s.get("luna_split", False),
145
+ "head_score": probe, "extractor_score": ext,
146
+ "band": decide(probe, p["origin"], thresholds),
147
+ })
148
+ bands = {m["band"] for m in mentions}
149
+ pband = ("unscored" if "unscored" in bands else
150
+ "confusion" if "confusion" in bands else
151
+ "mixed" if len(bands) > 1 else bands.pop())
152
+ items.append({
153
+ "queue": "gliner", "origin": p["origin"], "split": p["split"],
154
+ "ctx": ctx, "n": len(mentions), "band": pband,
155
+ "mentions": mentions, "scored_by": a.model,
156
+ })
157
+
158
+ row = 0
159
+ with torch.no_grad():
160
+ for batch in loader:
161
+ out = model.run_batch(batch, threshold=0.1, move_to_device=True)
162
+ W = out.words_embedding.detach().float()
163
+ mask = (out.mask.detach().cpu()
164
+ if getattr(out, "mask", None) is not None else None)
165
+ decoded = model.decode_batch(out, batch, threshold=0.1,
166
+ flat_ner=True, multi_label=False)
167
+ B = W.shape[0]
168
+ for bi in range(B):
169
+ vi = row + bi
170
+ oi = v2o[vi]
171
+ p = chosen[oi]
172
+ if oi not in set(v2o): # unreachable; v2o IS the valid map
173
+ continue
174
+ w = W[bi].to(device)
175
+ L = int(mask[bi].sum()) if mask is not None else w.shape[0]
176
+ starts = prepared["start_token_map"][vi]
177
+ proposals = [(int(sp.start), int(sp.end), float(sp.score))
178
+ for sp in decoded[bi]]
179
+ probes, extractors = [], []
180
+ for ner in p["ner"]:
181
+ # token grid -> char grid -> inference word grid
182
+ t0, t1 = ner[0], ner[1]
183
+ char_offs = char_maps[oi]
184
+ cs = char_offs[t0]
185
+ ce = char_offs[t1] + len(p["tokenized_text"][t1])
186
+ g0, g1 = char_to_infer_word(starts, cs, ce)
187
+ probe = ext = None
188
+ if g1 < L and g0 < L:
189
+ idx = torch.arange(g0, g1 + 1, device=device)
190
+ parts = [w[g0], w[g1], w[idx].mean(dim=0)]
191
+ if radius > 0:
192
+ w0, w1 = max(0, g0 - radius), min(g1 + radius, L - 1)
193
+ parts.append(w[w0:w1 + 1].mean(dim=0))
194
+ probe = float(torch.sigmoid(
195
+ head(torch.cat(parts).unsqueeze(0))).item())
196
+ n_probe += 1
197
+ hit = [sc for (ps, pe, sc) in proposals
198
+ if ps == g0 and pe == g1]
199
+ if hit:
200
+ ext = hit[0]
201
+ n_ext += 1
202
+ probes.append(probe)
203
+ extractors.append(ext)
204
+ flush(p, texts[oi], char_maps[oi], probes, extractors)
205
+ row += B
206
+
207
+ OUT.write_text("\n".join(json.dumps(it) for it in items) + "\n")
208
+ from collections import Counter
209
+ bands = Counter(m["band"] for it in items for m in it["mentions"])
210
+ lu = Counter(m["luna"] for it in items for m in it["mentions"])
211
+ print(f"queue: passages={len(items)} spans={sum(it['n'] for it in items)} "
212
+ f"probe_scored={n_probe} extractor_matched={n_ext} "
213
+ f"unscored={n_skip} bands={dict(bands)} luna={dict(lu)} -> {OUT}",
214
+ flush=True)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
build_gold_queue.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build the gold-controls queue from local human-adjudicated spans.
3
+
4
+ Joins outputs/gliner_datause_v3_probe_human473.jsonl (key, set, surface)
5
+ to analysis/v24_sample_review/gliner2_review.jsonl
6
+ (spans[].key -> input passage, start/end, origin). Scores are the
7
+ singlepass infer-head H100 predictions
8
+ (outputs/gliner-datause-catchall-infer-probe/human473_predictions.jsonl);
9
+ truth labels are deliberately excluded (blind controls).
10
+ Splits: annotator190 (set=annotator) + jdc283 (set=jdc).
11
+
12
+ uv run python human_labeling/build_gold_queue.py [--out human_labeling/queue_human473.json]
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+
22
+ from probe_labels import decide # noqa: E402
23
+
24
+ REPO = Path(__file__).resolve().parent.parent
25
+ HUMAN = REPO / "outputs" / "gliner_datause_v3_probe_human473.jsonl"
26
+ REVIEW = REPO / "analysis" / "v24_sample_review" / "gliner2_review.jsonl"
27
+
28
+
29
+ def main() -> None:
30
+ ap = argparse.ArgumentParser()
31
+ ap.add_argument("--out", default=str(REPO / "human_labeling" / "queue_human473.json"))
32
+ a = ap.parse_args()
33
+
34
+ # singlepass (infer-head) rescore, published H100 predictions; 23 of 473
35
+ # out-of-grid spans absent here -> head_score None, band "unscored"
36
+ SP = REPO / "outputs" / "gliner-datause-catchall-infer-probe" / "human473_predictions.jsonl"
37
+ sp = {}
38
+ if SP.exists():
39
+ for line in SP.read_text().splitlines():
40
+ if line.strip():
41
+ o = json.loads(line)
42
+ sp[o["key"]] = float(o.get("head_score"))
43
+ # review spans by key -> (passage, start, end, origin)
44
+ ctx: dict[str, tuple] = {}
45
+ for line in REVIEW.read_text().splitlines():
46
+ if not line.strip():
47
+ continue
48
+ row = json.loads(line)
49
+ for s in row.get("spans", []):
50
+ if s.get("key"):
51
+ ctx[s["key"]] = (row.get("input", ""), s.get("start"),
52
+ s.get("end"), row.get("origin"))
53
+ items: list[dict] = []
54
+ missing: list[str] = []
55
+ from collections import Counter
56
+ for line in HUMAN.read_text().splitlines():
57
+ if not line.strip():
58
+ continue
59
+ r = json.loads(line)
60
+ key = r["key"]
61
+ subset = "annotator190" if r.get("set") == "annotator" else "jdc283"
62
+ score = sp.get(key) # singlepass infer-head (H100); None = out-of-grid
63
+ if key in ctx:
64
+ passage, start, end, origin = ctx[key]
65
+ items.append({
66
+ "key": key, "surface": r.get("surface"), "ctx": passage,
67
+ "ctx_missing": False, "head_score": score,
68
+ "start": start, "end": end,
69
+ "band": decide(score, origin), "origin": origin,
70
+ "specificity": "", "split": "holdout",
71
+ "queue": "human473", "subset": subset,
72
+ "scored_by": "rafmacalaba/gliner-datause-catchall-infer-probe (H100)",
73
+ })
74
+ else:
75
+ missing.append(key)
76
+ Path(a.out).write_text("\n".join(json.dumps(i) for i in items) + "\n")
77
+ print(f"queued={len(items)} missing_ctx={len(missing)} "
78
+ f"scored={sum(1 for i in items if i['head_score'] is not None)} "
79
+ f"{Counter(i['subset'] for i in items)} -> {a.out}")
80
+ if missing:
81
+ print("missing:", missing[:5])
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
build_passage_queue.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Group scored flat pool items into passage examples (multi-mention).
3
+
4
+ Reads the flat rescored queue (build_pool_queue.py → rescore_singlepass.py)
5
+ and emits one JSON object per distinct (origin, ctx):
6
+
7
+ {queue, origin, ctx, n, band, scored_by,
8
+ mentions: [{key, surface, start, end, head_score, band}...]} # by start
9
+
10
+ Passages with one mention stay as-is; multi stays multi — the UI drives
11
+ them as one example with a per-mention ruling target. The passage-level
12
+ band is the most-informative mention state:
13
+
14
+ unscored any unscored
15
+ confusion any confusion
16
+ mixed keep + drop, no confusion
17
+ else the single uniform band
18
+
19
+ uv run python human_labeling/build_passage_queue.py \
20
+ [--input human_labeling/queue.json] [--out human_labeling/queue_passages.json]
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ from collections import defaultdict
26
+ from pathlib import Path
27
+
28
+ HERE = Path(__file__).resolve().parent
29
+
30
+
31
+ def passage_band(mentions: list[dict]) -> str:
32
+ bands = {m.get("band") for m in mentions}
33
+ if "unscored" in bands:
34
+ return "unscored"
35
+ if "confusion" in bands:
36
+ return "confusion"
37
+ if len(bands) == 1:
38
+ return bands.pop()
39
+ return "mixed"
40
+
41
+
42
+ def main() -> None:
43
+ ap = argparse.ArgumentParser()
44
+ ap.add_argument("--input", default=str(HERE / "queue.json"))
45
+ ap.add_argument("--out", default=str(HERE / "queue_passages.json"))
46
+ a = ap.parse_args()
47
+
48
+ groups: dict[tuple, list[dict]] = defaultdict(list)
49
+ for line in Path(a.input).read_text().splitlines():
50
+ if not line.strip():
51
+ continue
52
+ r = json.loads(line)
53
+ if r.get("ctx"):
54
+ groups[(r.get("origin"), r["ctx"])].append(r)
55
+
56
+ passages = []
57
+ for (origin, ctx), rows in groups.items():
58
+ mentions = sorted(
59
+ ({"key": r["key"], "surface": r.get("surface"),
60
+ "start": r.get("start"), "end": r.get("end"),
61
+ "head_score": r.get("head_score"), "band": r.get("band")}
62
+ for r in rows),
63
+ key=lambda m: (m["start"] if isinstance(m["start"], int) else 0,
64
+ m["surface"] or ""))
65
+ passages.append({
66
+ "queue": "probe_candidates/passages",
67
+ "origin": origin, "ctx": ctx, "n": len(mentions),
68
+ "band": passage_band(mentions), "mentions": mentions,
69
+ "scored_by": rows[0].get("scored_by"),
70
+ })
71
+
72
+ passages.sort(key=lambda p: (p["origin"] or "", p["ctx"] or ""))
73
+ Path(a.out).write_text(
74
+ "\n".join(json.dumps(p) for p in passages) + "\n")
75
+ n_multi = sum(1 for p in passages if p["n"] > 1)
76
+ from collections import Counter
77
+ print(f"passages={len(passages)} multi={n_multi} "
78
+ f"{Counter(p['band'] for p in passages)} -> {a.out}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
build_pool_queue.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build an annotation queue from rafmacalaba/datause-ner via datasets-server.
3
+
4
+ Stdlib only (urllib) so it runs anywhere, including Spaces builds.
5
+
6
+ uv run python human_labeling/build_pool_queue.py [--config probe_candidates] [--split pool]
7
+ [--limit 500] [--out human_labeling/queue.json]
8
+
9
+ Defaults target the annotatable config: probe_candidates/pool is the only
10
+ one with an embedded `passage`. --config probe_splits yields surface-only
11
+ triage items flagged ctx_missing (no passage on the Hub; see README).
12
+ Stratified sample: round-robin over origin x score-band so the queue
13
+ covers keep/confusion/drop and every origin instead of the head-heavy
14
+ random draw (proxy bands on the Hub score; true tags come from rescore).
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import urllib.parse
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ REPO = Path(__file__).resolve().parent
24
+ DATASET = "rafmacalaba/datause-ner"
25
+ API = "https://datasets-server.huggingface.co/rows"
26
+
27
+ BANDS = (("drop", 0.0, 0.05), ("confusion", 0.05, 0.9), ("keep", 0.9, 1.01))
28
+ # Proxy strata on the Hub (v3-era) score, used ONLY to stratify the sample
29
+ # across the eventual decision zones (same 0.05/0.9 edges as the audit
30
+ # deck). True keep/confusion/drop labels are assigned later by
31
+ # rescore_singlepass.probe_labels.decide; the written item band is always
32
+ # "unscored" until then.
33
+ def band_of(score: float) -> str:
34
+ for name, lo, hi in BANDS:
35
+ if lo <= score < hi:
36
+ return name
37
+ return "confusion"
38
+
39
+
40
+ def fetch_rows(config: str, split: str, offset: int, length: int) -> list[dict]:
41
+ import time
42
+ import urllib.error
43
+ q = urllib.parse.urlencode(
44
+ {"dataset": DATASET, "config": config, "split": split,
45
+ "offset": offset, "length": length}
46
+ )
47
+ last = None
48
+ for attempt in range(6):
49
+ try:
50
+ with urllib.request.urlopen(f"{API}?{q}", timeout=60) as r:
51
+ payload = json.load(r)
52
+ return [d["row"] for d in payload.get("rows", [])]
53
+ except urllib.error.HTTPError as e:
54
+ last = e
55
+ if e.code not in (429, 500, 502, 503):
56
+ raise
57
+ time.sleep(2 ** attempt)
58
+ raise last
59
+ def to_item(row: dict, config: str, split: str) -> dict:
60
+ score = float(row.get("head_score") or 0.0)
61
+ passage = row.get("passage")
62
+ start, end = row.get("start"), row.get("end")
63
+ item = {
64
+ "key": row.get("key"),
65
+ "surface": row.get("surface"),
66
+ "ctx": passage, # None for probe_splits rows (no passage on Hub)
67
+ "ctx_missing": passage is None,
68
+ "head_score": score, # Hub v3-era placeholder; rescore overwrites
69
+ "start": start, "end": end,
70
+ "band": "unscored", # rescore_singlepass tags keep/confusion/drop
71
+ "origin": row.get("origin"),
72
+ "specificity": row.get("extractor_label") or row.get("stratum") or "",
73
+ "split": row.get("split", split),
74
+ "queue": f"{config}/{split}",
75
+ }
76
+ return item
77
+
78
+
79
+ def main() -> None:
80
+ ap = argparse.ArgumentParser()
81
+ ap.add_argument("--config", default="probe_candidates")
82
+ ap.add_argument("--split", default="pool")
83
+ ap.add_argument("--limit", type=int, default=500)
84
+ ap.add_argument("--fetch", type=int, default=5000,
85
+ help="rows scanned from the Hub for stratification")
86
+ ap.add_argument("--seed", type=int, default=7)
87
+ ap.add_argument("--out", default=str(REPO / "queue.json"))
88
+ a = ap.parse_args()
89
+
90
+ scanned: list[dict] = []
91
+ offset = 0
92
+ while len(scanned) < a.fetch:
93
+ batch = fetch_rows(a.config, a.split, offset, min(100, a.fetch - len(scanned)))
94
+ if not batch:
95
+ break
96
+ scanned.extend(batch)
97
+ offset += len(batch)
98
+
99
+ # stratify: round-robin over (origin, band)
100
+ import random
101
+ rng = random.Random(a.seed)
102
+ buckets: dict[tuple, list[dict]] = {}
103
+ for row in scanned:
104
+ item = to_item(row, a.config, a.split)
105
+ buckets.setdefault((item["origin"], item["band"]), []).append(item)
106
+ for b in buckets.values():
107
+ rng.shuffle(b)
108
+ queue: list[dict] = []
109
+ while len(queue) < min(a.limit, len(scanned)) and buckets:
110
+ for k in sorted(buckets):
111
+ if len(queue) >= a.limit:
112
+ break
113
+ if buckets[k]:
114
+ queue.append(buckets[k].pop())
115
+ buckets = {k: v for k, v in buckets.items() if v}
116
+ rng.shuffle(queue)
117
+
118
+ out = Path(a.out)
119
+ out.write_text("\n".join(json.dumps(q) for q in queue) + "\n")
120
+ origins = sorted({q["origin"] for q in queue})
121
+ missing = sum(1 for q in queue if q["ctx_missing"])
122
+ print(f"scanned={len(scanned)} queued={len(queue)} origins={origins} "
123
+ f"ctx_missing={missing} -> {out}")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
index.html CHANGED
@@ -1,19 +1,326 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <title>datause-ner labeling · passage queue</title>
5
+ <style>
6
+ :root{
7
+ --bg:#F6F4EF; --card:#FFFFFF; --line:#E7E3DD; --ink:#211D1A; --mut:#6F6A63;
8
+ --primary:#4F46E5; --primary-ink:#FFFFFF;
9
+ --keep:#0F7A4D; --keep-bg:#E2F5E8; --keep-line:#7BC7A2;
10
+ --drop:#C2412B; --drop-bg:#FCE8E3; --drop-line:#E8A394;
11
+ --conf:#B45309; --conf-bg:#FBF0DC; --conf-line:#E0B37A;
12
+ --unsc:#6B7280; --unsc-bg:#F1F3F5;
13
+ --mark-u:#FDF0DA; --mark-u-line:#D97706; --mark-k:#DCFCE7; --mark-k-line:#22A55B;
14
+ --mark-d:#FEE2E2; --mark-d-line:#DC2626;
15
+ }
16
+ *{box-sizing:border-box}
17
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:"Fira Sans","Segoe UI",system-ui,sans-serif;font-size:15px;line-height:1.6}
18
+ header{position:sticky;top:0;z-index:30;background:rgba(255,255,255,.94);backdrop-filter:blur(6px);border-bottom:1px solid var(--line)}
19
+ .hrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:10px 20px}
20
+ header h1{font-size:14px;margin:0;letter-spacing:.2px;font-weight:800}
21
+ .sub{font-size:12.5px;color:var(--mut)}
22
+ #bar{height:5px;background:var(--line);border-radius:3px;margin:2px 0}
23
+ #bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--primary),#22A55B);border-radius:3px;transition:width .25s}
24
+ main{max-width:1120px;margin:0 auto;padding:18px 20px 110px;display:grid;grid-template-columns:1fr 356px;gap:22px;align-items:start}
25
+ @media(max-width:920px){main{grid-template-columns:1fr}}
26
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:0 1px 2px rgba(33,29,26,.05),0 10px 28px rgba(33,29,26,.05)}
27
+ .passage-card{padding:16px 20px 14px}
28
+ .grid-2{display:grid;grid-template-columns:auto 1fr;gap:18px}
29
+ .badges{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
30
+ .badge{font-size:11px;font-weight:700;padding:3px 10px;border-radius:999px;background:var(--unsc-bg);color:var(--unsc)}
31
+ .b-keep{background:var(--keep-bg);color:var(--keep)}.b-drop{background:var(--drop-bg);color:var(--drop)}
32
+ .b-confusion{background:var(--conf-bg);color:var(--conf)}.b-mixed{background:#E9E6FF;color:#4338CA}.b-unscored{background:var(--unsc-bg);color:var(--unsc)}
33
+ .passage{max-height:min(54vh,560px);overflow-y:auto;font-size:14.5px;line-height:1.8;padding:14px 6px;border:1px solid var(--line);border-radius:12px;background:#FDFCFA}
34
+ .passage mark{cursor:pointer;border-radius:3px;padding:0 2px;font-weight:600}
35
+ mark.m-u{background:var(--mark-u);border-bottom:2px solid var(--mark-u-line)}
36
+ mark.m-k{background:var(--mark-k);border-bottom:2px solid var(--mark-k-line)}
37
+ mark.m-d{background:var(--mark-d);border-bottom:2px solid var(--mark-d-line)}
38
+ mark.on{outline:2px solid var(--primary);outline-offset:1px}
39
+ mark sup{font-size:9.5px;font-weight:800;color:var(--primary);margin-left:1px}
40
+ .legend{display:flex;gap:8px;align-items:center;margin-top:8px;font-size:11.5px;color:var(--mut);flex-wrap:wrap}
41
+ .legend i{display:inline-block;width:20px;height:4px;border-radius:2px}
42
+ .legend .u i{background:var(--mark-u);border-bottom:2px solid var(--mark-u-line)}
43
+ .legend .k i{background:var(--mark-k);border-bottom:2px solid var(--mark-k-line)}
44
+ .legend .d i{background:var(--mark-d);border-bottom:2px solid var(--mark-d-line)}
45
+ .legend .a i{outline:2px solid var(--primary);outline-offset:1px}
46
+ .chips{display:flex;gap:7px;align-items:center;overflow-x:auto;margin-top:10px;padding:7px 4px;scrollbar-width:thin}
47
+ .chips::-webkit-scrollbar{height:5px}.chips::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
48
+ .chip{font:inherit;font-size:11.5px;cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:5px 11px;white-space:nowrap;display:inline-flex;gap:5px;align-items:center;transition:all .15s}
49
+ .chip b{width:18px;height:18px;border-radius:50%;font-size:10px;display:inline-grid;place-items:center;color:#fff}
50
+ .chip .dot{width:7px;height:7px;border-radius:50%;background:var(--conf-line)}
51
+ .chip.r-k{background:var(--keep-bg);border-color:var(--keep-line);color:var(--keep)}
52
+ .chip.r-d{background:var(--drop-bg);border-color:var(--drop-line);color:var(--drop)}
53
+ .chip.on{outline:2px solid var(--primary);outline-offset:1px}
54
+ .chip .sc{font-variant-numeric:tabular-nums}
55
+ .panel-card{padding:16px 18px;position:sticky;top:96px;display:flex;flex-direction:column;gap:12px}
56
+ .panel-label{font-size:10.5px;text-transform:uppercase;letter-spacing:.8px;color:var(--mut);font-weight:800}
57
+ .surface{font-size:20px;font-weight:800;line-height:1.35;padding:10px 12px;background:#FBF8F1;border:1px solid #EADFC4;border-radius:12px}
58
+ .pstate{font-size:13px;font-weight:700}
59
+ .pstate.un{color:var(--conf)}.pstate.k{color:var(--keep)}.pstate.d{color:var(--drop)}
60
+ .scoreline{display:flex;gap:7px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut)}
61
+ .tag{font-size:11px;font-weight:800;padding:2px 9px;border-radius:999px;letter-spacing:.3px}
62
+ .tag.k{background:var(--keep-bg);color:var(--keep)}.tag.d{background:var(--drop-bg);color:var(--drop)}
63
+ .tag.c{background:var(--conf-bg);color:var(--conf)}.tag.u{background:var(--unsc-bg);color:var(--unsc)}
64
+ .note{font:inherit;font-size:13.5px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:#FDFCFA;width:100%}
65
+ .note:focus{outline:2px solid var(--primary);outline-offset:0;border-color:var(--primary)}
66
+ .hint{font-size:11.5px;color:var(--mut);line-height:1.55}
67
+ kbd{font-family:inherit;font-size:10.5px;background:var(--unsc-bg);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
68
+ #dock{position:fixed;left:0;right:0;bottom:0;z-index:30;background:rgba(255,255,255,.97);backdrop-filter:blur(6px);border-top:1px solid var(--line);padding:11px 20px;display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap}
69
+ button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:12px;padding:10px 18px;font-size:13.5px;transition:all .15s}
70
+ button.nav:hover{border-color:var(--primary);color:var(--primary)}
71
+ button:disabled{opacity:.4;cursor:default}
72
+ button.keep{background:var(--keep);border-color:var(--keep);color:#fff;font-weight:800;min-width:132px;box-shadow:0 1px 3px rgba(15,122,77,.35)}
73
+ button.keep:hover{filter:brightness(1.05)}
74
+ button.drop{background:var(--drop);border-color:var(--drop);color:#fff;font-weight:800;min-width:132px;box-shadow:0 1px 3px rgba(194,65,43,.35)}
75
+ button.drop:hover{filter:brightness(1.05)}
76
+ button.primary{background:var(--primary);border-color:var(--primary);color:#fff}
77
+ select,input[type=text]{font:inherit;font-size:12.5px;padding:6px 10px;border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--ink)}
78
+ .state{font-size:12.5px;color:var(--mut);min-width:180px;text-align:center;font-variant-numeric:tabular-nums}
79
+ #who{width:76px}
80
+ .btnrow{display:flex;gap:8px;align-items:center}
81
+ .foot{text-align:center;color:var(--mut);font-size:12px;padding-top:14px}
82
+ .empty{text-align:center;color:var(--mut);padding:60px 0;font-size:16px}
83
+ .warn{background:#FBF0DC;border:1px solid var(--conf-line);border-radius:10px;padding:8px 12px;font-size:12.5px}
84
+ </style></head><body>
85
+ <header>
86
+ <div class="hrow">
87
+ <h1>datause-ner labeling</h1>
88
+ <span class="sub" id="qname">queue</span>
89
+ <span class="sub" id="prog" style="font-weight:800;color:var(--ink)"></span><span style="flex:1"></span>
90
+ <label class="sub">annotator <input type="text" id="who" placeholder="initials" style="width:76px"></label>
91
+ <label class="sub"><input type="checkbox" id="rev"> scores</label>
92
+ <select id="fQueue" title="queue"></select>
93
+ <select id="fOrigin" title="origin filter"></select>
94
+ <select id="fBand" title="band filter"></select>
95
+ <select id="fLuna" title="Luna verdict vs model">
96
+ <option value="">luna×model: all</option><option value="agree">agree</option>
97
+ <option value="conflict">conflict</option><option value="unsure">model unsure</option></select>
98
+ <button class="primary" onclick="exportRulings()">⬇ Export</button>
99
+ <span class="sub" id="sink"></span>
100
+ </div>
101
+ <div id="bar"><i></i></div>
102
+ </header>
103
+ <main>
104
+ <div class="card passage-card">
105
+ <div class="badges"><span class="badge" id="bOrigin"></span><span class="badge" id="bQueue"></span><span class="badge" id="bBand"></span><span class="badge" id="bKey"></span></div>
106
+ <div id="noctx" class="warn" style="display:none">surface-only item — no passage available.</div>
107
+ <div class="passage" id="passage"></div>
108
+ <div class="legend">
109
+ <span class="u"><i></i> unruled</span><span class="k"><i></i> kept</span><span class="d"><i></i> dropped</span><span class="a"><i></i> active</span>
110
+ </div>
111
+ <div class="chips" id="chips"></div>
112
+ <div style="margin-top:4px"></div>
113
+ </div>
114
+ <div class="card panel-card">
115
+ <div class="panel-label">Ruling target</div>
116
+ <div class="surface" id="surface"></div>
117
+ <div class="scoreline"><span id="pos"></span><span class="sc" id="sc"></span></div>
118
+ <div class="pstate" id="pstate">unruled</div>
119
+ <div class="btnrow">
120
+ <button class="keep" id="bKeep" onclick="rule('DATA_MENTION')">✓ Keep</button>
121
+ <button class="drop" id="bDrop" onclick="rule('NON_MENTION')">✕ Drop</button>
122
+ </div>
123
+ <input class="note" id="note" placeholder="note (optional) — saved with the ruling">
124
+ <div class="hint"><kbd>k</kbd> keep · <kbd>d</kbd> drop · <kbd>←</kbd><kbd>→</kbd> mention · <kbd>↑</kbd><kbd>↓</kbd> passage · <kbd>s</kbd> skip · <kbd>z</kbd> undo</div>
125
+ </div>
126
+ <div class="empty" id="empty" style="display:none">Nothing matches the active filters.</div>
127
+ </main>
128
+ <div id="dock">
129
+ <button class="nav" id="bPPrev" onclick="moveP(-1)">⏮ P</button>
130
+ <button class="nav" id="bMPrev" onclick="moveM(-1)">‹ M <span id="mPos"></span></button>
131
+ <button class="keep" onclick="rule('DATA_MENTION')">✓ Keep</button>
132
+ <button class="drop" onclick="rule('NON_MENTION')">✕ Drop</button>
133
+ <button class="nav" id="bMNext" onclick="moveM(1)"><span id="mPos2"></span> M ›</button>
134
+ <button class="nav" id="bPNext" onclick="moveP(1)">P ⏭</button>
135
+ <button class="nav" onclick="moveP(1)">Skip <small>(s)</small></button>
136
+ <button class="nav" id="bUndo" onclick="undo()">⟲ Undo</button>
137
+ <span class="state" id="state"></span>
138
+ </div>
139
+ <script>
140
+ let Q=[], R={}, pidx=0, mi=0, lastRuled=null;
141
+ const $=id=>document.getElementById(id);
142
+ const LS='datause_ner_rulings_v1';
143
+ try{R=JSON.parse(localStorage.getItem(LS)||'{}')}catch(e){R={}}
144
+ $('who').value=localStorage.getItem('datause_ner_who')||'';
145
+ $('who').oninput=()=>localStorage.setItem('datause_ner_who',$('who').value);
146
+ const save=()=>localStorage.setItem(LS,JSON.stringify(R));
147
+ const esc=s=>String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
148
+ const mentionsOf=q=>Array.isArray(q.mentions)&&q.mentions.length?q.mentions:[{
149
+ key:q.key,surface:q.surface,start:q.start,end:q.end,
150
+ head_score:q.head_score,band:q.band}];
151
+ const isFlat=q=>!Array.isArray(q.mentions);
152
+ async function post(ruling){
153
+ try{const r=await fetch('/api/rulings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(ruling)});
154
+ if(r.ok){$('sink').textContent='● server ('+(await r.json()).n+')';return}}catch(e){}
155
+ $('sink').textContent='● local export';
156
+ }
157
+ async function load(){
158
+ try{const r=await fetch('/api/queue');if(r.ok)Q=(await r.json()).items||[]}catch(e){}
159
+ if(!Q.length){
160
+ for(const f of ['./queue_gliner.json']){
161
+ try{const r=await fetch(f);if(r.ok)Q.push(...(await r.text()).split('\n').filter(Boolean).map(JSON.parse))}catch(e){}
162
+ }
163
+ }
164
+ fillFilters();render();
165
+ }
166
+ const lunaState=m=>{
167
+ if(m.luna==null||m.head_score==null)return null;
168
+ if(m.luna_split)return 'conflict'; // Luna itself split on the same span
169
+ if(m.band==='keep')return m.luna===1?'agree':'conflict';
170
+ if(m.band==='drop')return m.luna===0?'agree':'conflict';
171
+ return 'unsure';
172
+ };
173
+ const filtered=()=>{const qu=$('fQueue').value,o=$('fOrigin').value,b=$('fBand').value,lu=$('fLuna').value;
174
+ return Q.map((q,i)=>({q,i})).filter(({q})=>(!qu||q.queue===qu)&&(!o||q.origin===o)&&(!b||q.band===b)
175
+ &&(!lu||mentionsOf(q).some(m=>lunaState(m)===lu)))};
176
+ const totalMentions=()=>Q.reduce((n,q)=>n+(q.mentions?.length||1),0);
177
+ function fillFilters(){
178
+ const qs=[...new Set(Q.map(q=>q.queue))].sort();
179
+ $('fQueue').innerHTML=qs.map(q=>`<option value="${esc(q)}">${esc(q)} · ${Q.filter(x=>x.queue===q).length}</option>`).join('');
180
+ if(qs.includes('gliner'))$('fQueue').value='gliner';
181
+ else if(qs.includes('probe_candidates/pool'))$('fQueue').value='probe_candidates/pool';
182
+ else if(qs.includes('human473'))$('fQueue').value='human473';
183
+ const os=[...new Set(Q.map(q=>q.origin))].sort(),bs=[...new Set(Q.map(q=>q.band))].sort();
184
+ $('fOrigin').innerHTML='<option value="">all origins</option>'+os.map(o=>`<option>${esc(o)}</option>`).join('');
185
+ $('fBand').innerHTML='<option value="">all bands</option>'+bs.map(b=>`<option>${esc(b)}</option>`).join('');
186
+ for(const id of ['fQueue','fOrigin','fBand','fLuna']) $(id).onchange=()=>{pidx=0;mi=0;render()};
187
+ $('rev').onchange=()=>render();
188
+ }
189
+ function bandsHTML(m){
190
+ const t=m.band==='keep'?'k':m.band==='drop'?'d':m.band==='unscored'?'u':'c';
191
+ return `<span class="tag ${t}">${esc(m.band||'?')}</span>`;
192
+ }
193
+ function contextHTML(q){
194
+ const p=q.ctx||'';const ms=mentionsOf(q);
195
+ const marks=[];
196
+ for(let i=0;i<ms.length;i++){
197
+ const m=ms[i];let s=m.start,e=m.end;
198
+ if(!(Number.isInteger(s)&&Number.isInteger(e)&&e>s&&p.slice(s,e)===m.surface)){
199
+ s=p.indexOf(m.surface||'');e=s<0?0:s+(m.surface||'').length;
200
+ }
201
+ if(s<0)continue;
202
+ marks.push({s,e,i});
203
+ }
204
+ // char-once walk: overlapping spans never duplicate passage text; a mark
205
+ // starting inside the previous one is suppressed in-text (chip remains)
206
+ marks.sort((a,b)=>a.s-b.s||b.e-a.e);
207
+ let out='',cur=0,suppressed=0;
208
+ for(const mk of marks){
209
+ if(mk.s<cur){suppressed++;continue}
210
+ out+=esc(p.slice(cur,mk.s));
211
+ const r=R[ms[mk.i].key];
212
+ const cls=r? (r.ruling==='DATA_MENTION'?'m-k':'m-d') : 'm-u';
213
+ out+=`<mark class="${cls}" data-mi="${mk.i}" onclick="clickM(${mk.i})">${esc(p.slice(mk.s,mk.e))}${ms.length>1?`<sup>${mk.i+1}</sup>`:''}</mark>`;
214
+ cur=mk.e;
215
+ }
216
+ out+=esc(p.slice(cur));
217
+ return out;
218
+ }
219
+ function render(){
220
+ const F=filtered();
221
+ const ruled=Object.keys(R).length, tm=totalMentions();
222
+ $('prog').textContent=`${ruled}/${tm} ruled`;
223
+ $('qname').textContent=`${$('fQueue').value||'queue'} · ${F.length}`;
224
+ $('empty').style.display=F.length?'none':'block';
225
+ const bar=document.querySelector('#bar>i');bar.style.width=(tm?100*ruled/tm:0)+'%';
226
+ if(!F.length){$('passage').innerHTML='';$('chips').innerHTML='';$('state').textContent='adjust filters';return}
227
+ pidx=Math.max(0,Math.min(pidx,F.length-1));
228
+ const q=F[pidx].q, ms=mentionsOf(q);
229
+ mi=Math.max(0,Math.min(mi,ms.length-1));
230
+ const m=ms[mi],r=R[m.key];
231
+ $('bOrigin').textContent=[q.origin,q.subset].filter(Boolean).join(' · ');
232
+ const nk=ms.filter(x=>x.luna===1).length,nd=ms.filter(x=>x.luna===0).length;
233
+ $('bQueue').textContent=(isFlat(q)?'single mention':`${q.mentions.length} mentions`)
234
+ +(nk+nd?` · luna: ${nk}✓ / ${nd}✕`:'');
235
+ const bb=$('bBand');bb.textContent=`tag: ${q.band}`;bb.className='badge b-'+(q.band||'unscored');
236
+ $('bKey').textContent=q.mentions?m.key:q.key;
237
+ $('noctx').style.display=q.ctx?'none':'block';
238
+ $('passage').innerHTML=q.ctx?contextHTML(q):`<div class="empty">${esc(m.surface||'(no context)')}</div>`;
239
+ for(const el of $('passage').querySelectorAll('mark[data-mi]')){
240
+ if(el.dataset.mi===String(mi))el.classList.add('on');
241
+ else el.classList.remove('on');
242
+ }
243
+ $('passage').style.display='';
244
+ const act=$('passage').querySelector('mark.on');
245
+ if(act)act.scrollIntoView({block:'center',behavior:'smooth'});
246
+ $('chips').innerHTML=ms.map((mm,i)=>{
247
+ const rr=R[mm.key];
248
+ const cls=rr?(rr.ruling==='DATA_MENTION'?'r-k':'r-d'):'';
249
+ const on=i===mi?'on':'';
250
+ const dot=mm.luna_split?'var(--conf)':mm.luna===1?'var(--keep)':mm.luna===0?'var(--drop)':'var(--conf-line)';
251
+ return `<button class="chip ${cls} ${on}" data-ma="${i}" onclick="clickM(${i})"><b>${i+1}</b><span class="dot" style="background:${dot}"></span>${esc(mm.surface)}</button>`;
252
+ }).join('');
253
+ const actChip=$('chips').querySelector('button.on');
254
+ if(actChip)actChip.scrollIntoView({inline:'center',behavior:'smooth',block:'nearest'});
255
+ const sc=$('rev').checked?(m.head_score==null?'n/a':(+m.head_score).toFixed(3)):'•••';
256
+ $('surface').textContent=m.surface||'(no surface)';
257
+ $('pos').textContent=`M ${mi+1}/${ms.length}`;
258
+ $('sc').innerHTML=bandsHTML(m)
259
+ +(m.luna_split?`<span class="tag c">Luna split ✓/✕</span>`
260
+ :m.luna!=null?`<span class="tag ${m.luna===1?'k':'d'}">Luna ${m.luna===1?'✓ kept':'✕ dropped'}</span>`:'')
261
+ +`<span class="sc">probe ${sc}</span>`
262
+ +(m.extractor_score!=null?`<span class="sc">ext ${(+m.extractor_score).toFixed(2)}</span>`:'');
263
+ const st=$('pstate');
264
+ if(r){st.textContent=(r.ruling==='DATA_MENTION'?'✓ kept':'✕ dropped')+' — your ruling';st.className='pstate '+(r.ruling==='DATA_MENTION'?'k':'d')}
265
+ else{st.textContent='unruled — model tag above is a hint, you decide';st.className='pstate un'}
266
+ $('note').value=r?.note||'';
267
+ $('state').textContent=`P ${pidx+1}/${F.length} · M ${mi+1}/${ms.length}${r?' · ruled':''}`;
268
+ $('mPos').textContent=`${mi+1}/${ms.length}`;$('mPos2').textContent=`${mi+1}/${ms.length}`;
269
+ $('bPPrev').disabled=pidx===0;$('bPNext').disabled=pidx>=F.length-1;
270
+ $('bMPrev').disabled=mi===0&&pidx===0;$('bMNext').disabled=mi>=ms.length-1&&pidx>=F.length-1;
271
+ $('bUndo').disabled=!lastRuled;
272
+ }
273
+ function clickM(i){mi=i;render()}
274
+ function moveM(d){
275
+ const F=filtered();if(!F.length)return;
276
+ const ms=mentionsOf(F[pidx].q);
277
+ if(d>0){if(mi<ms.length-1)mi++;else if(pidx<F.length-1){pidx++;mi=0} }
278
+ else{if(mi>0)mi--;else if(pidx>0){pidx--;mi=mentionsOf(F[pidx].q).length-1} }
279
+ render();
280
+ }
281
+ function moveP(d){
282
+ const F=filtered();if(!F.length)return;
283
+ pidx=Math.max(0,Math.min(pidx+d,F.length-1));mi=0;render();
284
+ }
285
+ async function rule(v){
286
+ const F=filtered();if(!F.length)return;
287
+ const q=F[pidx].q,ms=mentionsOf(q),m=ms[mi];
288
+ R[m.key]={key:m.key,ruling:v,annotator:$('who').value||'anon',ts:new Date().toISOString(),
289
+ note:$('note').value||'',head_score:m.head_score,origin:q.origin,surface:m.surface,queue:q.queue};
290
+ lastRuled=m.key;save();post(R[m.key]);
291
+ if(mi<ms.length-1)mi++;
292
+ else if(pidx<F.length-1){pidx++;mi=0}
293
+ render();
294
+ }
295
+ function undo(){
296
+ if(!lastRuled)return;
297
+ const F=filtered();
298
+ let found=false;
299
+ for(let fi=0;fi<F.length;fi++){
300
+ const ms=mentionsOf(F[fi].q);
301
+ for(let j=0;j<ms.length;j++){
302
+ if(ms[j].key===lastRuled){pidx=fi;mi=j;found=true;break}
303
+ }
304
+ if(found)break;
305
+ }
306
+ delete R[lastRuled];lastRuled=null;save();
307
+ if(!found){$('state').textContent='ruled item outside current filters';return}
308
+ render();
309
+ }
310
+ function exportRulings(){
311
+ const blob=new Blob([JSON.stringify({items:Object.values(R)},null,1)],{type:'application/json'});
312
+ const a=document.createElement('a');a.href=URL.createObjectURL(blob);
313
+ a.download=`rulings-${$('who').value||'anon'}-export.json`;a.click();
314
+ }
315
+ document.addEventListener('keydown',e=>{
316
+ if(/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;
317
+ if(e.key==='k')rule('DATA_MENTION');
318
+ else if(e.key==='d')rule('NON_MENTION');
319
+ else if(e.key==='ArrowRight')moveM(1);
320
+ else if(e.key==='ArrowLeft')moveM(-1);
321
+ else if(e.key==='ArrowDown'||e.key==='s')moveP(1);
322
+ else if(e.key==='ArrowUp')moveP(-1);
323
+ else if(e.key==='z')undo();
324
+ });
325
+ load();
326
+ </script></body></html>
merge.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Reconcile annotation files into one adjudicated row per key.
3
+
4
+ uv run python human_labeling/merge.py [--out annotations/adjudicated.jsonl]
5
+
6
+ Reads every annotations/rulings*.jsonl + annotations/*-export.json
7
+ (export format: {"items": [...]}, same ruling objects), majority-votes
8
+ ties -> "REVIEW", and writes adjudicated.jsonl:
9
+ {"key", "verdict", "n_keep", "n_drop", "annotators", "origins"?, ...}
10
+ Kept inputs are never modified; rerun any time.
11
+ """
12
+
13
+ import argparse
14
+ import glob
15
+ import json
16
+ from pathlib import Path
17
+
18
+ HERE = Path(__file__).resolve().parent
19
+ ANNOT = HERE / "annotations"
20
+
21
+
22
+ def load() -> list[dict]:
23
+ rulings: list[dict] = []
24
+ for f in sorted(glob.glob(str(ANNOT / "rulings*.jsonl"))):
25
+ for line in open(f):
26
+ line = line.strip()
27
+ if line:
28
+ rulings.append(json.loads(line))
29
+ for f in sorted(glob.glob(str(ANNOT / "*-export.json"))):
30
+ try:
31
+ payload = json.load(open(f))
32
+ except json.JSONDecodeError:
33
+ continue
34
+ items = payload.get("items", payload if isinstance(payload, list) else [])
35
+ rulings.extend(i for i in items if isinstance(i, dict) and i.get("key"))
36
+ return rulings
37
+
38
+
39
+ def main() -> None:
40
+ ap = argparse.ArgumentParser()
41
+ ap.add_argument("--out", default=str(ANNOT / "adjudicated.jsonl"))
42
+ a = ap.parse_args()
43
+ by_key: dict[str, list[dict]] = {}
44
+ for r in load():
45
+ by_key.setdefault(r["key"], []).append(r)
46
+ n_tie = 0
47
+ with open(a.out, "w") as f:
48
+ for key in sorted(by_key):
49
+ rs = by_key[key]
50
+ keep = sum(1 for r in rs if r["ruling"] == "DATA_MENTION")
51
+ drop = sum(1 for r in rs if r["ruling"] == "NON_MENTION")
52
+ verdict = "DATA_MENTION" if keep > drop else "NON_MENTION" if drop > keep else "REVIEW"
53
+ n_tie += verdict == "REVIEW"
54
+ f.write(json.dumps({
55
+ "key": key, "verdict": verdict, "n_keep": keep, "n_drop": drop,
56
+ "n": len(rs), "annotators": sorted({r.get("annotator", "?") for r in rs}),
57
+ "surface": rs[0].get("surface"), "origin": rs[0].get("origin"),
58
+ "head_score": rs[0].get("head_score"),
59
+ }) + "\n")
60
+ print(f"keys={len(by_key)} ties={n_tie} -> {a.out}")
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
probe_labels.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision labels for queue items from the singlepass bundle thresholds.
2
+
3
+ Rule (shared by build_gold_queue.py and rescore_singlepass.py so every
4
+ queue files the same vocabulary):
5
+
6
+ keep score >= per-origin best-F1 threshold (thresholds.json)
7
+ drop score <= DROP_FLOOR (confident reject, repo 0.05 edge)
8
+ confusion everything between — the human-review zone (was "mid")
9
+ unscored score missing (out-of-grid span / not yet rescored)
10
+
11
+ Per-origin table (published singlepass bundle, best-F1):
12
+
13
+ fcv_pads_east_africa 0.5 · general_prwp 0.4 · jad_paddy_docs 0.1
14
+ jdc_operational 0.6 · refugee_pads 0.5 · reliefweb 0.4
15
+ fallback: global_best 0.5
16
+ """
17
+
18
+ import json
19
+ from pathlib import Path
20
+
21
+ DROP_FLOOR = 0.05
22
+
23
+ _THR: dict | None = None
24
+
25
+
26
+ def load_thresholds() -> dict:
27
+ """Published singlepass thresholds; mirror of the bundle's thresholds.json."""
28
+ global _THR
29
+ if _THR is None:
30
+ p = (Path(__file__).resolve().parent.parent / "outputs"
31
+ / "gliner-datause-catchall-infer-probe" / "thresholds.json")
32
+ _THR = json.loads(p.read_text()) if p.exists() else {}
33
+ return _THR
34
+
35
+
36
+ def decide(score, origin, thresholds: dict | None = None) -> str:
37
+ if score is None:
38
+ return "unscored"
39
+ t = thresholds if thresholds is not None else load_thresholds()
40
+ thr = (t.get("per_origin") or {}).get(origin, {}).get("thr")
41
+ if thr is None:
42
+ thr = (t.get("global_best") or {}).get("thr", 0.5)
43
+ if score >= thr:
44
+ return "keep"
45
+ if score <= DROP_FLOOR:
46
+ return "drop"
47
+ return "confusion"
queue_annotate_aivin.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_annotate_aj.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_annotate_paddy.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_annotate_rafael.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_annotate_sample.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_gliner.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_human473.json ADDED
The diff for this file is too large to render. See raw diff
 
queue_review473.json ADDED
The diff for this file is too large to render. See raw diff
 
rescore_singlepass.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Rescore labeling queues with the singlepass infer head (local MPS).
3
+
4
+ Same weights the H100 fit (probe_head.pt in the singlepass bundle), applied
5
+ to fixed queue spans via training/probe_features_infer.encode_split_infer:
6
+ one prompt-conditioned forward per passage batch, score =
7
+ sigmoid(head([start; end; mean; +-64 window])).
8
+
9
+ Scoring happens only for items without scored_by (or --force). Every queue
10
+ item — scored here or carrying authoritative H100 scores from
11
+ build_gold_queue.py — is then tagged with the singlepass decision rule
12
+ (human_labeling/probe_labels.py):
13
+
14
+ keep score >= per-origin best-F1 threshold (thresholds.json)
15
+ drop score <= 0.05
16
+ confusion between threshold and the drop floor (was "mid")
17
+ unscored score missing (out-of-grid span)
18
+
19
+ uv run python human_labeling/rescore_singlepass.py [--force] [--limit N]
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ REPO = Path(__file__).resolve().parent.parent
28
+ sys.path.insert(0, str(REPO))
29
+
30
+ MODEL = "rafmacalaba/gliner-datause-catchall-singlepass"
31
+ QUEUES = ["human_labeling/queue.json", "human_labeling/queue_human473.json"]
32
+
33
+
34
+ def main() -> None:
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("--model", default=MODEL)
37
+ ap.add_argument("--force", action="store_true")
38
+ ap.add_argument("--limit", type=int, default=0)
39
+ ap.add_argument("--batch", type=int, default=8)
40
+ a = ap.parse_args()
41
+
42
+ import torch
43
+ from training.singlepass_infer import default_device, load_bundle
44
+ from training.probe_features_infer import encode_split_infer
45
+ from probe_labels import decide, load_thresholds
46
+
47
+ device = default_device()
48
+ print(f"device={device} model={a.model}", flush=True)
49
+ model, head, bundle = load_bundle(
50
+ "rafmacalaba/gliner-datause-mentions-catch-all", a.model, device)
51
+ thresholds = bundle.get("thresholds") or load_thresholds()
52
+ args = argparse.Namespace(encode_batch_size=a.batch, context_radius=64)
53
+
54
+ for qname in QUEUES:
55
+ p = REPO / qname
56
+ if not p.exists():
57
+ continue
58
+ items = [json.loads(l) for l in p.read_text().splitlines() if l.strip()]
59
+ todo = [(i, r) for i, r in enumerate(items) if r.get("ctx")]
60
+ to_score = [(i, r) for i, r in todo if a.force or not r.get("scored_by")]
61
+ if a.limit:
62
+ to_score = to_score[:a.limit]
63
+
64
+ n_scored = n_skip = skipped = 0
65
+ if to_score:
66
+ texts = [r["ctx"] for _, r in to_score]
67
+ spans = [(k, r["start"], r["end"], 0, r["key"])
68
+ for k, (_, r) in enumerate(to_score)]
69
+ feats, dim, skipped = encode_split_infer(model, texts, spans, args, device)
70
+ F = torch.from_numpy(feats).to(device)
71
+ with torch.no_grad():
72
+ probs = torch.sigmoid(head(F)).cpu().numpy().ravel()
73
+ for (i, r), frow, ps in zip(to_score, feats, probs):
74
+ if not frow.any(): # out-of-grid: zero row, not a real score
75
+ r["head_score"] = None
76
+ n_skip += 1
77
+ else:
78
+ r["head_score"] = float(ps)
79
+ n_scored += 1
80
+ r["scored_by"] = a.model
81
+
82
+ n_tag = 0
83
+ for i, r in todo:
84
+ r["band"] = decide(r.get("head_score"), r.get("origin"), thresholds)
85
+ n_tag += 1
86
+ if to_score or n_tag:
87
+ p.write_text("\n".join(json.dumps(r) for r in items) + "\n")
88
+ print(f"{qname}: rescored={n_scored} unscored={n_skip} "
89
+ f"tagged={n_tag} skipped_align={skipped}", flush=True)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
review.html ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>annotation review</title>
7
+ <style>
8
+ :root, [data-theme="light"] { --bg: #f6f8fa; --card: #ffffff; --fg: #1f2328; --mut: #59636e; --line: #d0d7de; --keep: #1a7f37; --drop: #d1242c; --hl: #0969da; --chip: #eaeef2; }
9
+ [data-theme="dark"] { --bg: #0f1419; --card: #1a222c; --fg: #e6edf3; --mut: #8b98a5; --line: #30363d; --keep: #3fb950; --drop: #f85149; --hl: #1f6feb; --chip: #21262d; }
10
+ * { box-sizing: border-box; }
11
+ body { background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, system-ui, sans-serif; margin: 0; }
12
+ header { position: sticky; top: 0; background: var(--card); border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; gap: 10px; align-items: center; z-index: 5; }
13
+ header .prog { font-variant-numeric: tabular-nums; white-space: nowrap; }
14
+ #bar { flex: 1; height: 8px; background: var(--chip); border-radius: 4px; overflow: hidden; }
15
+ #bar > div { height: 100%; background: var(--hl); width: 0; }
16
+ select, button { font: inherit; color: var(--fg); }
17
+ main { max-width: 860px; margin: 0 auto; padding: 20px 16px 90px; }
18
+ .meta { color: var(--mut); font-size: 13px; margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
19
+ #passage { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; font-size: 16px; }
20
+ #passage mark { background: #9e6a03; color: #fff; border-radius: 3px; padding: 0 2px; cursor: pointer; }
21
+ #passage mark.active { outline: 2px solid var(--hl); }
22
+ #passage mark.done-k { background: var(--keep); }
23
+ #passage mark.done-d { background: var(--drop); }
24
+ #chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
25
+ .chip { background: var(--chip); border: 1px solid var(--line); border-radius: 14px; padding: 2px 12px; font-size: 13px; cursor: pointer; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
+ .chip.active { outline: 2px solid var(--hl); }
27
+ .chip.k { border-color: var(--keep); } .chip.d { border-color: var(--drop); }
28
+ #ref { margin-top: 10px; font-size: 13px; color: var(--mut); background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
29
+ #ref b.k { color: var(--keep); } #ref b.d { color: var(--drop); }
30
+ .controls { display: flex; gap: 10px; margin-top: 14px; }
31
+ button { cursor: pointer; border-radius: 8px; border: 1px solid var(--line); background: var(--card); padding: 10px 22px; }
32
+ button.keep { border-color: var(--keep); } button.keep.on { background: var(--keep); color: #fff; }
33
+ button.drop { border-color: var(--drop); } button.drop.on { background: var(--drop); color: #fff; }
34
+ button.nav { padding: 10px 14px; }
35
+ #mine { color: var(--mut); font-size: 13px; margin-top: 8px; min-height: 20px; }
36
+ kbd { background: var(--chip); border-radius: 4px; padding: 1px 6px; font-size: 12px; }
37
+ footer { position: fixed; bottom: 0; left: 0; right: 0; background: var(--card); border-top: 1px solid var(--line); padding: 8px 16px; display: flex; gap: 10px; align-items: center; font-size: 13px; color: var(--mut); }
38
+ footer .sp { flex: 1; }
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <header>
43
+ <span class="prog" id="prog">–/–</span>
44
+ <div id="bar"><div></div></div>
45
+ <select id="ds" onchange="loadDs()">
46
+ <option value="sample" selected>annotate sample · 60</option>
47
+ <option value="gold">gold · 473</option>
48
+ <option value="paddy">paddy · 240</option>
49
+ <option value="aj">aj · 240</option>
50
+ <option value="aivin">aivin · 240</option>
51
+ <option value="rafael">rafael · 240</option>
52
+ </select>
53
+ <select id="src" onchange="reset()"></select>
54
+ <input id="who" placeholder="your name to annotate" autocomplete="off"
55
+ style="font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;max-width:190px">
56
+ <button onclick="theme()" title="toggle theme">◐</button>
57
+ </header>
58
+ <main>
59
+ <div class="meta"><span id="key"></span><span id="surface"></span></div>
60
+ <div id="passage"></div>
61
+ <div id="chips"></div>
62
+ <div id="ref"></div>
63
+ <div class="controls">
64
+ <button class="nav" onclick="move(-1)">← prev</button>
65
+ <button class="keep" id="bKeep" onclick="rule('DATA_MENTION')">keep <kbd>k</kbd></button>
66
+ <button class="drop" id="bDrop" onclick="rule('NON_MENTION')">drop <kbd>d</kbd></button>
67
+ <button class="nav" onclick="move(1)">next →</button>
68
+ </div>
69
+ <div id="mine"></div>
70
+ <footer>
71
+ <span><kbd>←</kbd><kbd>→</kbd> prev/next mention · <kbd>k</kbd> keep · <kbd>d</kbd> drop · <kbd>space</kbd> skip · <kbd>[</kbd><kbd>]</kbd> slide · <kbd>-</kbd><kbd>+</kbd> size · <kbd>e</kbd> export</span>
72
+ <span class="sp"></span><span id="count"></span>
73
+ <input id="hubtok" type="password" placeholder="HF token" title="write token for data-use-annotations; only needed on static hosting without the bundled server"
74
+ style="font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;max-width:130px">
75
+ <button id="hubBtn" onclick="exp()">export</button>
76
+ </footer>
77
+ <script>
78
+ const DS = { sample: './queue_annotate_sample.json', gold: './queue_review473.json',
79
+ paddy: './queue_annotate_paddy.json', aj: './queue_annotate_aj.json',
80
+ aivin: './queue_annotate_aivin.json', rafael: './queue_annotate_rafael.json' };
81
+ const USERS = ['paddy', 'aj', 'aivin', 'rafael'];
82
+ const LS = 'review_rulings';
83
+ let R = JSON.parse(localStorage.getItem(LS) || '{}');
84
+ const $ = id => document.getElementById(id);
85
+ const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
86
+ const QD = document.documentElement;
87
+ QD.dataset.theme = localStorage.getItem('review_theme') || 'light';
88
+ function theme() { QD.dataset.theme = QD.dataset.theme === 'light' ? 'dark' : 'light'; localStorage.setItem('review_theme', QD.dataset.theme); }
89
+ const who = () => ($('who').value || '').trim();
90
+ function loadWho() {
91
+ $('who').value = localStorage.getItem('review_who') || '';
92
+ $('who').addEventListener('input', () => {
93
+ localStorage.setItem('review_who', who());
94
+ paintGate();
95
+ const w = who().toLowerCase();
96
+ if (USERS.includes(w) && $('ds').value !== w) { $('ds').value = w; loadDs(); }
97
+ });
98
+ $('hubtok').value = localStorage.getItem('review_hubtok') || '';
99
+ $('hubtok').addEventListener('input', () => localStorage.setItem('review_hubtok', $('hubtok').value.trim()));
100
+ paintGate();
101
+ }
102
+ function paintGate() {
103
+ const ok = who().length > 0;
104
+ $('who').style.borderColor = ok ? '' : 'var(--drop)';
105
+ $('bKeep').disabled = $('bDrop').disabled = !ok;
106
+ $('bKeep').style.opacity = $('bDrop').style.opacity = ok ? '' : '0.45';
107
+ }
108
+ function needName() {
109
+ if (who().length > 0) return false;
110
+ $('mine').textContent = 'enter your name above before annotating';
111
+ $('who').focus();
112
+ return true;
113
+ }
114
+
115
+ // normalize legacy single-span items -> spans[]
116
+ function norm(o) {
117
+ if (o.spans) return o;
118
+ return {...o, spans: [{key: o.key, start: o.start, end: o.end, surface: o.surface,
119
+ luna_label: o.ref ? (o.ref.luna_label === 1 ? 1 : 0) : undefined,
120
+ human_verdict: o.ref && o.ref.human_verdict, human_note: o.ref && o.ref.human_note,
121
+ probe_tag: undefined, probe_score: o.ref && o.ref.head_score,
122
+ pred: o.ref && o.ref.pred}]};
123
+ }
124
+ async function loadDs() {
125
+ const q = await (await fetch(DS[$('ds').value])).json();
126
+ ALL = q.map(norm);
127
+ const srcs = [...new Set(ALL.map(o => o.source))].sort();
128
+ const total = ALL.length;
129
+ $('src').innerHTML = `<option value="all">all · ${total}</option>` +
130
+ srcs.map(x => `<option value="${esc(x)}">${esc(x)} · ${ALL.filter(o => o.source === x).length}</option>`).join('');
131
+ reset();
132
+ }
133
+ function reset() {
134
+ const f = $('src').value;
135
+ ITEMS = ALL.filter(o => f === 'all' || o.source === f);
136
+ i = 0; s = 0; needCenter = true; render();
137
+ }
138
+ function cur() { return ITEMS[i]; }
139
+ function span() { return cur().spans[s]; }
140
+ let WSIZES = [400, 800, 1600, Infinity], wi = 1, wc = null, plo = 0, phi = 0, needCenter = true;
141
+ function wlabel() { return WSIZES[wi] === Infinity ? 'full' : '±' + WSIZES[wi] / 2; }
142
+ function render() {
143
+ const o = cur();
144
+ if (!o) return;
145
+ s = Math.min(s, o.spans.length - 1);
146
+ const sp = span();
147
+ $('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;
148
+ document.querySelector('#bar > div').style.width = (100 * (i + 1) / ITEMS.length) + '%';
149
+ $('key').textContent = sp.key;
150
+ $('surface').textContent = '“' + sp.surface + '”';
151
+ // sliding assessment window around the active span
152
+ if (needCenter || wc === null) { wc = (sp.start + sp.end) / 2; needCenter = false; }
153
+ const W = WSIZES[wi], L = o.text.length;
154
+ let lo = 0, hi = L;
155
+ if (W !== Infinity && L > W) {
156
+ lo = Math.max(0, Math.floor(wc - W / 2)); hi = Math.min(L, lo + W);
157
+ if (hi - lo < W) lo = Math.max(0, hi - W);
158
+ const snap = (n, dir) => { while (n > 0 && n < L && o.text[n] !== ' ' && o.text[n] !== '\n') n += dir; return n; };
159
+ lo = snap(lo, -1); hi = snap(hi, 1);
160
+ lo = Math.min(lo, sp.start); hi = Math.max(hi, sp.end);
161
+ }
162
+ const pts = [];
163
+ if (lo > 0) pts.push('<span style="color:var(--mut)">…</span>');
164
+ let p = lo;
165
+ const ordered = [...o.spans].sort((a, b) => a.start - b.start).filter(x => x.end > lo && x.start < hi);
166
+ for (const x of ordered) {
167
+ const a = Math.max(x.start, lo), b = Math.min(x.end, hi);
168
+ pts.push(esc(o.text.slice(p, a)));
169
+ const cls = [x.key === sp.key ? 'active' : '', R[x.key] === 'DATA_MENTION' ? 'done-k' : '', R[x.key] === 'NON_MENTION' ? 'done-d' : ''].join(' ');
170
+ pts.push(`<mark data-k="${esc(x.key)}" class="${cls}">${esc(o.text.slice(a, b))}</mark>`);
171
+ p = b;
172
+ }
173
+ pts.push(esc(o.text.slice(p, hi)));
174
+ plo = lo; phi = hi;
175
+ $('passage').innerHTML = pts.join('');
176
+ bindMarks(o);
177
+ renderChips(o);
178
+ renderRef(span());
179
+ lightMeta();
180
+ }
181
+ function renderChips(o) {
182
+ $('chips').innerHTML = o.spans.length > 1 ? o.spans.map((x, n) =>
183
+ `<span class="chip${n === s ? ' active' : ''}${R[x.key] === 'DATA_MENTION' ? ' k' : ''}${R[x.key] === 'NON_MENTION' ? ' d' : ''}" data-n="${n}">#${n + 1} ${esc(x.surface.slice(0, 40))}</span>`).join('') : '';
184
+ document.querySelectorAll('.chip').forEach(c => c.onclick = () => show(i, +c.dataset.n));
185
+ }
186
+ function renderRef(sp) {
187
+ let ref;
188
+ if (sp.luna_label !== undefined && sp.human_verdict) {
189
+ const g = sp.luna_label === 1 ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';
190
+ ref = `gold ${g} · human ${esc(sp.human_verdict)}${sp.human_note ? ' (' + esc(sp.human_note) + ')' : ''} · model ${sp.pred} @ ${sp.probe_score}`;
191
+ } else {
192
+ const g = sp.luna_label === 1 ? '<b class="k">KEEP</b>' : '<b class="d">DROP</b>';
193
+ ref = `luna ${g} · probe ${esc(sp.probe_tag)} @ ${sp.probe_score}`;
194
+ }
195
+ $('ref').innerHTML = ref;
196
+ }
197
+ let lastSaved = '';
198
+ function lightMeta() {
199
+ const mine = R[span().key];
200
+ $('bKeep').className = 'keep' + (mine === 'DATA_MENTION' ? ' on' : '');
201
+ $('bDrop').className = 'drop' + (mine === 'NON_MENTION' ? ' on' : '');
202
+ $('mine').textContent = mine ? `your ruling: ${mine === 'DATA_MENTION' ? 'keep' : 'drop'}` : '';
203
+ $('count').textContent = Object.keys(R).length + ' ruled' + (lastSaved ? ' · last save: ' + lastSaved : '');
204
+ }
205
+ function bindMarks(o) {
206
+ document.querySelectorAll('#passage mark').forEach(m => m.onclick = () => {
207
+ show(i, o.spans.findIndex(x => x.key === m.dataset.k));
208
+ });
209
+ }
210
+ function light() {
211
+ // same passage, text stays fixed: only the active highlight changes
212
+ const o = cur(), sp = span();
213
+ $('prog').textContent = `${i + 1}/${ITEMS.length} · span ${s + 1}/${o.spans.length}`;
214
+ $('key').textContent = sp.key;
215
+ $('surface').textContent = '“' + sp.surface + '”';
216
+ document.querySelectorAll('#passage mark').forEach(m => {
217
+ m.classList.toggle('active', m.dataset.k === sp.key);
218
+ m.classList.toggle('done-k', R[m.dataset.k] === 'DATA_MENTION');
219
+ m.classList.toggle('done-d', R[m.dataset.k] === 'NON_MENTION');
220
+ });
221
+ renderChips(o);
222
+ renderRef(sp);
223
+ lightMeta();
224
+ }
225
+ function show(ni, ns) {
226
+ // same passage: text stays fixed, only the highlight moves.
227
+ // recenter only when the target span lies outside the shown window.
228
+ if (ni !== i) { i = ni; s = ns; needCenter = true; render(); return; }
229
+ s = Math.max(0, Math.min(ns, cur().spans.length - 1));
230
+ const c = (span().start + span().end) / 2;
231
+ if (c < plo || c > phi) { needCenter = true; render(); } else light();
232
+ }
233
+ function move(d) { show((i + d + ITEMS.length) % ITEMS.length, 0); }
234
+ function smove(d) {
235
+ const o = cur();
236
+ if (s + d >= 0 && s + d < o.spans.length) show(i, s + d);
237
+ else if (d > 0 && i < ITEMS.length - 1) show(i + 1, 0);
238
+ else if (d < 0 && i > 0) show(i - 1, ITEMS[i - 1].spans.length - 1);
239
+ else show(i, d > 0 ? o.spans.length - 1 : 0);
240
+ }
241
+ async function rule(v) {
242
+ if (needName()) return;
243
+ const k = span().key;
244
+ R[k] = v;
245
+ localStorage.setItem(LS, JSON.stringify(R));
246
+ paintGate();
247
+ const entry = {key: k, ruling: v, queue: $('ds').value, annotator: who()};
248
+ let saved = 'this browser only';
249
+ try {
250
+ const r = await fetch('/api/rulings', {method: 'POST', headers: {'Content-Type': 'application/json'},
251
+ body: JSON.stringify(entry)});
252
+ if (r.ok) saved = (await r.json()).pushed ? 'server + Hub' : 'server';
253
+ else if (r.status === 404) saved = await hubCommit(entry);
254
+ } catch (e) {
255
+ saved = await hubCommit(entry);
256
+ }
257
+ lastSaved = saved;
258
+ // next unruled span in passage, else next passage
259
+ const o = cur();
260
+ const nx = o.spans.findIndex((x, n) => n > s && !R[x.key]);
261
+ if (nx >= 0) show(i, nx);
262
+ else if (i < ITEMS.length - 1) show(i + 1, 0);
263
+ else { light(); }
264
+ }
265
+ async function hubCommit(entry) {
266
+ // static hosting has no ruling sink: commit straight to the public
267
+ // dataset with the annotator's own write token (stored locally only).
268
+ const tok = ($('hubtok').value || '').trim();
269
+ if (!tok) return 'this browser only (add HF token to sync)';
270
+ try {
271
+ const repo = 'rafmacalaba/data-use-annotations';
272
+ const who = entry.annotator.replace(/[^a-zA-Z0-9-_]/g, '_');
273
+ const path = `rulings/${who}.jsonl`;
274
+ let rows = [];
275
+ try {
276
+ const r = await fetch(`https://huggingface.co/datasets/${repo}/resolve/main/${path}`);
277
+ if (r.ok) rows = (await r.text()).split('\n').filter(Boolean).map(JSON.parse);
278
+ } catch (e) {}
279
+ rows = rows.filter(x => !(x.queue === entry.queue && x.key === entry.key));
280
+ rows.push({...entry, ts: new Date().toISOString()});
281
+ const content = btoa(unescape(encodeURIComponent(rows.map(x => JSON.stringify(x)).join('\n') + '\n')));
282
+ const fd = new FormData();
283
+ fd.append('operations', JSON.stringify({key: 'headerFile', value: {path, content}}) + '\n');
284
+ fd.append('commitTitle', `ruling ${who}: ${entry.key}=${entry.ruling} (web)`);
285
+ const res = await fetch(`https://huggingface.co/api/datasets/${repo}/commit/main`, {
286
+ method: 'POST', headers: {Authorization: `Bearer ${tok}`}, body: fd});
287
+ if (!res.ok) throw new Error('hub ' + res.status);
288
+ return 'Hub';
289
+ } catch (e) { return 'this browser only (Hub sync failed)'; }
290
+ }
291
+ document.addEventListener('keydown', e => {
292
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
293
+ if (e.key === 'ArrowRight') smove(1);
294
+ else if (e.key === 'ArrowLeft') smove(-1);
295
+ else if (e.key === '[') { wc = Math.max(0, (wc ?? 0) - WSIZES[wi] / 2); render(); }
296
+ else if (e.key === ']') { wc = (wc ?? 0) + WSIZES[wi] / 2; render(); }
297
+ else if (e.key === '-' || e.key === '_') { wi = Math.max(0, wi - 1); render(); }
298
+ else if (e.key === 'k' || e.key === 'K') rule('DATA_MENTION');
299
+ else if (e.key === 'd' || e.key === 'D') rule('NON_MENTION');
300
+ else if (e.key === ' ') { e.preventDefault(); skip(); }
301
+ else if (e.key === 'e' || e.key === 'E') exp();
302
+ });
303
+ function skip() {
304
+ const o = cur();
305
+ const nx = o.spans.findIndex((x, n) => n > s && !R[x.key]);
306
+ if (nx >= 0) show(i, nx);
307
+ else if (i < ITEMS.length - 1) show(i + 1, 0);
308
+ }
309
+ loadWho();
310
+ loadDs();
311
+ </script>
312
+ </body>
313
+ </html>