Spaces:
Running
Running
annotation review app (per-user queues, Hub-backed rulings, static-safe direct commit)
Browse files- .dockerignore +2 -0
- Dockerfile +5 -0
- README.md +98 -10
- __pycache__/probe_labels.cpython-314.pyc +0 -0
- __pycache__/queue.cpython-314.pyc +0 -0
- annotations/.gitkeep +1 -0
- annotations/rulings.jsonl +0 -0
- app.py +122 -0
- build_gliner2_queue.py +195 -0
- build_gliner_queue.py +218 -0
- build_gold_queue.py +85 -0
- build_passage_queue.py +82 -0
- build_pool_queue.py +127 -0
- index.html +325 -18
- merge.py +64 -0
- probe_labels.py +47 -0
- queue_annotate_aivin.json +0 -0
- queue_annotate_aj.json +0 -0
- queue_annotate_paddy.json +0 -0
- queue_annotate_rafael.json +0 -0
- queue_annotate_sample.json +0 -0
- queue_gliner.json +0 -0
- queue_human473.json +0 -0
- queue_review473.json +0 -0
- rescore_singlepass.py +93 -0
- review.html +313 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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 => ({'&':'&','<':'<','>':'>'}[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>
|