GitHub Actions commited on
Commit
feebf4d
·
1 Parent(s): c667464

Sync from GitHub

Browse files
README.md CHANGED
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  # LLM Annotation Platform — Hugging Face native
2
 
3
  This version removes the external database layer.
 
1
+ ---
2
+ title: LLM Annotation Platform
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
  # LLM Annotation Platform — Hugging Face native
11
 
12
  This version removes the external database layer.
hf-space/.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ SOURCE_DATASET_REPO=nvidia/CantTalkAboutThis-Topic-Control-Dataset
2
+ SOURCE_DATASET_SPLIT=train
3
+ ANNOTATION_REPO_ID=YOUR_ORG/llm-distractor-annotations
4
+ HF_TOKEN=
5
+ CACHE_DIR=/data/hf_annotation_cache
6
+ DRAFT_DIR=/data/hf_annotation_drafts
7
+ EXPORT_DIR=/data/hf_annotation_exports
hf-space/.github/workflows/sync-to-hf.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ sync-to-hub:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v4
15
+ with:
16
+ lfs: true
17
+
18
+ - name: Push to Hugging Face
19
+ env:
20
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
21
+ run: |
22
+ git config --global user.email "github-actions@github.com"
23
+ git config --global user.name "GitHub Actions"
24
+
25
+ git clone https://user:$HF_TOKEN@huggingface.co/spaces/keepingLLMontrack/llm-annotation-platform hf-space
26
+
27
+ rsync -av --exclude '.git' ./ hf-space/
28
+
29
+ cd hf-space
30
+
31
+ git add .
32
+
33
+ git commit -m "Sync from GitHub" || echo "No changes to commit"
34
+
35
+ git push
hf-space/.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .streamlit/
4
+ data/
5
+ exports/
6
+ .env
7
+ .DS_Store
hf-space/Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . /app
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ EXPOSE 7860
10
+
11
+ CMD ["streamlit", "run", "app.py", "--server.port", "7860", "--server.address", "0.0.0.0"]
hf-space/README.md CHANGED
@@ -1,10 +1,84 @@
1
- ---
2
- title: Llm Annotation Platform
3
- emoji: 🦀
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LLM Annotation Platform — Hugging Face native
2
+
3
+ This version removes the external database layer.
4
+
5
+ ## What it uses
6
+
7
+ - **Hugging Face Space** for the Streamlit app
8
+ - **Hugging Face dataset repo** for the canonical annotation store
9
+ - **Hugging Face Storage Bucket** only for persistent local cache / drafts in the Space
10
+ - **No Supabase**
11
+ - **No separate backend platform**
12
+
13
+ Hugging Face Spaces provide ephemeral disk by default, and Hugging Face recommends attaching Storage Buckets to persist data across restarts. Buckets are mounted into the Space container as local volumes. citeturn322583view0
14
+
15
+ ## Repository structure
16
+
17
+ ```text
18
+ app.py
19
+ scripts/seed.py
20
+ requirements.txt
21
+ README.md
22
+ ```
23
+
24
+ ## Behavior
25
+
26
+ Each annotation is written as its own JSON file into the dataset repository:
27
+ ```text
28
+ annotations/<annotator>/<timestamp>_<item_id>_<uuid>.json
29
+ ```
30
+
31
+ That design avoids write conflicts between annotators because each submission is a new file, not an overwrite of a shared database row. Repository files on the Hub are versioned, and the Hub supports uploading files to dataset repositories. citeturn322583view1turn322583view4
32
+
33
+ ## Local run
34
+
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ streamlit run app.py
38
+ ```
39
+
40
+ ## How to set it up on Hugging Face
41
+
42
+ ### 1. Create two dataset repositories
43
+
44
+ Create:
45
+ - one dataset repo for the **source / seed data**
46
+ - one dataset repo for the **annotations**
47
+
48
+ Hugging Face dataset repositories are created from the Hub UI, and dataset files plus revision history are stored in the repository. citeturn322583view1
49
+
50
+ ### 2. Create a Space
51
+
52
+ Create a **Streamlit** Space and connect it to your GitHub repository. Spaces host apps directly on the Hub and support Streamlit as a built-in SDK. citeturn322583view2
53
+
54
+ ### 3. Attach a Storage Bucket
55
+
56
+ Attach a Storage Bucket to the Space and mount it at `/data`.
57
+
58
+ This is the only stateful storage used by the app. It stores drafts and cache files and survives restarts. Hugging Face documents Storage Buckets as the recommended persistence mechanism for Spaces. citeturn322583view0
59
+
60
+ ### 4. Add secrets
61
+
62
+ In the Space settings, add:
63
+ - `HF_TOKEN` — a Hugging Face token with **write** permission
64
+ - `SOURCE_DATASET_REPO`
65
+ - `SOURCE_DATASET_SPLIT`
66
+ - `ANNOTATION_REPO_ID`
67
+
68
+ Hugging Face recommends using Space secrets or environment variables instead of hard-coding sensitive values. A write token is required to create repositories or push content to the Hub. citeturn322583view2turn322583view4
69
+
70
+ ### 5. Deploy
71
+
72
+ Commit the repo to GitHub. Once the Space is linked, it will build from the repository, and the app can upload annotation files to the dataset repo using the Hub API. Hugging Face’s Hub client supports `upload_file()` and `create_commit()` for repository writes. citeturn322583view3turn322583view4
73
+
74
+ ## Suggested workflow for your group
75
+
76
+ - each person uses a stable annotator name
77
+ - each submission creates a new JSON file in the annotation repo
78
+ - the Review page shows items with 2+ annotations
79
+ - the Dashboard shows per-annotator and per-domain progress
80
+ - exports are generated from the merged source + annotation view
81
+
82
+ ## Why this is a good fit
83
+
84
+ The original source dataset can still be loaded with `datasets.load_dataset(...)`, and the Hugging Face ecosystem is designed for pushing and versioning datasets directly on the Hub. The `datasets` library also provides a `push_to_hub()` path for dataset publishing, while `huggingface_hub` provides lower-level file upload methods when you want more control over file layout. citeturn674332search1turn674332search3turn322583view3
hf-space/app.py ADDED
@@ -0,0 +1,853 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ import pandas as pd
11
+ import streamlit as st
12
+ from datasets import load_dataset
13
+ from huggingface_hub import HfApi, hf_hub_download
14
+
15
+ APP_TITLE = "🧭 LLM Annotation Platform"
16
+ DEFAULT_SOURCE_DATASET = os.environ.get(
17
+ "SOURCE_DATASET_REPO",
18
+ "nvidia/CantTalkAboutThis-Topic-Control-Dataset",
19
+ )
20
+ DEFAULT_SOURCE_SPLIT = os.environ.get("SOURCE_DATASET_SPLIT", "train")
21
+ DEFAULT_ANNOTATION_REPO = os.environ.get(
22
+ "ANNOTATION_REPO_ID",
23
+ "YOUR_ORG/llm-distractor-annotations",
24
+ )
25
+ DEFAULT_CACHE_DIR = Path(os.environ.get("CACHE_DIR", "/data/hf_annotation_cache"))
26
+ DEFAULT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
27
+ LOCAL_DRAFT_DIR = Path(os.environ.get("DRAFT_DIR", "/data/hf_annotation_drafts"))
28
+ LOCAL_DRAFT_DIR.mkdir(parents=True, exist_ok=True)
29
+ LOCAL_EXPORT_DIR = Path(os.environ.get("EXPORT_DIR", "/data/hf_annotation_exports"))
30
+ LOCAL_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
31
+
32
+ LABEL_OPTIONS = {
33
+ "distractor_kind": [
34
+ "benign off-topic",
35
+ "smooth bridge",
36
+ "policy-evasive",
37
+ "roleplay / impersonation",
38
+ "pressure / persistence",
39
+ "urgency / time pressure",
40
+ "loophole seeking",
41
+ "multi-turn escalation",
42
+ "other",
43
+ ],
44
+ "assistant_behavior": [
45
+ "perfect refusal + redirect",
46
+ "helpful redirection",
47
+ "partial engagement",
48
+ "full engagement / derailment",
49
+ "over-refusal",
50
+ "unclear",
51
+ ],
52
+ "transition_style": [
53
+ "abrupt",
54
+ "smooth bridge",
55
+ "follow-up clarification",
56
+ "rephrasing",
57
+ "escalation",
58
+ "roleplay",
59
+ "ambiguity exploitation",
60
+ "other",
61
+ ],
62
+ "policy_target": [
63
+ "medical advice",
64
+ "financial advice",
65
+ "legal advice",
66
+ "competitor discussion",
67
+ "politics",
68
+ "unsafe content",
69
+ "personal data",
70
+ "company-specific policy",
71
+ "tone / style policy",
72
+ "other",
73
+ ],
74
+ }
75
+
76
+
77
+ def now_iso() -> str:
78
+ return datetime.now(timezone.utc).isoformat()
79
+
80
+
81
+ def token() -> Optional[str]:
82
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
83
+
84
+
85
+ def api() -> HfApi:
86
+ return HfApi(token=token())
87
+
88
+
89
+ def annotation_file_name(item_id: str, annotator: str) -> str:
90
+ safe_annotator = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in annotator.strip().lower()) or "annotator"
91
+ safe_item = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in item_id.strip()) or "item"
92
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
93
+ return f"annotations/{safe_annotator}/{stamp}_{safe_item}_{uuid.uuid4().hex[:8]}.json"
94
+
95
+
96
+ def draft_path(annotator: str) -> Path:
97
+ safe_annotator = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in annotator.strip().lower()) or "annotator"
98
+ return LOCAL_DRAFT_DIR / f"{safe_annotator}.json"
99
+
100
+
101
+ def cache_annotations_dir() -> Path:
102
+ path = DEFAULT_CACHE_DIR / "annotations_snapshot"
103
+ path.mkdir(parents=True, exist_ok=True)
104
+ return path
105
+
106
+
107
+ def ensure_repo_exists(repo_id: str) -> None:
108
+ if repo_id.startswith("YOUR_ORG/") or not repo_id.strip():
109
+ return
110
+ api().create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True)
111
+
112
+
113
+ def load_source_dataset(repo_id: str, split: str) -> List[Dict[str, Any]]:
114
+ ds = load_dataset(repo_id, split=split)
115
+ return [dict(row) for row in ds]
116
+
117
+
118
+ def normalize_turns(turns: Any) -> List[Dict[str, Any]]:
119
+ if turns is None:
120
+ return []
121
+ if isinstance(turns, str):
122
+ try:
123
+ turns = json.loads(turns)
124
+ except Exception:
125
+ return []
126
+ if not isinstance(turns, list):
127
+ return []
128
+ out = []
129
+ for turn in turns:
130
+ if isinstance(turn, dict):
131
+ role = turn.get("role") or turn.get("speaker") or turn.get("type") or "unknown"
132
+ content = turn.get("content") or turn.get("text") or turn.get("utterance") or ""
133
+ out.append({"role": str(role), "content": str(content)})
134
+ else:
135
+ out.append({"role": "unknown", "content": str(turn)})
136
+ return out
137
+
138
+
139
+ def safe_sample_id(record: Dict[str, Any], fallback_index: int) -> str:
140
+ for key in ("sample_id", "id", "_id", "row_id"):
141
+ if record.get(key) not in (None, ""):
142
+ return str(record[key])
143
+ domain = str(record.get("domain", "sample")).replace(" ", "_")
144
+ scenario = str(record.get("scenario", "")).replace(" ", "_")
145
+ return f"{domain}-{scenario}-{fallback_index}"
146
+
147
+
148
+ def expand_record(record: Dict[str, Any], idx: int) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
149
+ sample_id = safe_sample_id(record, idx)
150
+ conversation = normalize_turns(record.get("conversation"))
151
+ distractors = record.get("distractors") or []
152
+ if isinstance(distractors, str):
153
+ try:
154
+ distractors = json.loads(distractors)
155
+ except Exception:
156
+ distractors = []
157
+ if not isinstance(distractors, list):
158
+ distractors = []
159
+
160
+ sample = {
161
+ "sample_id": sample_id,
162
+ "domain": str(record.get("domain", "")),
163
+ "scenario": str(record.get("scenario", "")),
164
+ "system_instruction": str(record.get("system_instruction", "")),
165
+ "conversation_json": json.dumps(conversation, ensure_ascii=False),
166
+ "distractors_json": json.dumps(distractors, ensure_ascii=False),
167
+ "conversation_with_distractors_json": json.dumps(record.get("conversation_with_distractors", []), ensure_ascii=False),
168
+ "raw_json": json.dumps(record, ensure_ascii=False),
169
+ }
170
+
171
+ items = []
172
+ for distractor_index, d in enumerate(distractors):
173
+ bot_turn = ""
174
+ distractor_text = ""
175
+ if isinstance(d, dict):
176
+ bot_turn = str(
177
+ d.get("bot turn")
178
+ or d.get("bot_turn")
179
+ or d.get("assistant_turn")
180
+ or d.get("assistant")
181
+ or ""
182
+ )
183
+ distractor_text = str(
184
+ d.get("distractor")
185
+ or d.get("distractor user turn")
186
+ or d.get("user_turn")
187
+ or d.get("user")
188
+ or d.get("text")
189
+ or ""
190
+ )
191
+ else:
192
+ distractor_text = str(d)
193
+
194
+ items.append(
195
+ {
196
+ "item_id": f"{sample_id}::{distractor_index}",
197
+ "sample_id": sample_id,
198
+ "distractor_index": distractor_index,
199
+ "bot_turn": bot_turn,
200
+ "distractor_text": distractor_text,
201
+ }
202
+ )
203
+ return sample, items
204
+
205
+
206
+ def seed_source_index(records: List[Dict[str, Any]]) -> Tuple[pd.DataFrame, pd.DataFrame]:
207
+ samples = []
208
+ items = []
209
+ for idx, record in enumerate(records):
210
+ sample, record_items = expand_record(record, idx)
211
+ samples.append(sample)
212
+ items.extend(record_items)
213
+ return pd.DataFrame(samples), pd.DataFrame(items)
214
+
215
+
216
+ def read_json_file(path: Path) -> Dict[str, Any]:
217
+ with path.open("r", encoding="utf-8") as f:
218
+ return json.load(f)
219
+
220
+
221
+ def load_all_hub_annotations(annotation_repo_id: str) -> pd.DataFrame:
222
+ """
223
+ Each submission is stored as a separate JSON file, which avoids write conflicts.
224
+ """
225
+ if annotation_repo_id.startswith("YOUR_ORG/") or not annotation_repo_id.strip():
226
+ return pd.DataFrame(columns=["item_id", "annotator", "labels", "notes", "status", "created_at", "file_path"])
227
+
228
+ cache_dir = cache_annotations_dir()
229
+ file_list = api().list_repo_files(annotation_repo_id, repo_type="dataset")
230
+ ann_files = [f for f in file_list if f.startswith("annotations/") and f.endswith(".json")]
231
+
232
+ rows = []
233
+ for file_path in ann_files:
234
+ try:
235
+ local_path = hf_hub_download(
236
+ repo_id=annotation_repo_id,
237
+ repo_type="dataset",
238
+ filename=file_path,
239
+ token=token(),
240
+ local_dir=str(cache_dir),
241
+ local_dir_use_symlinks=False,
242
+ )
243
+ payload = read_json_file(Path(local_path))
244
+ rows.append(
245
+ {
246
+ "item_id": payload.get("item_id", ""),
247
+ "sample_id": payload.get("sample_id", ""),
248
+ "annotator": payload.get("annotator", ""),
249
+ "labels": payload.get("labels", {}),
250
+ "notes": payload.get("notes", ""),
251
+ "status": payload.get("status", "submitted"),
252
+ "created_at": payload.get("created_at", ""),
253
+ "file_path": file_path,
254
+ }
255
+ )
256
+ except Exception as e:
257
+ rows.append(
258
+ {
259
+ "item_id": "",
260
+ "sample_id": "",
261
+ "annotator": "",
262
+ "labels": {},
263
+ "notes": f"Failed to load {file_path}: {e}",
264
+ "status": "load_error",
265
+ "created_at": "",
266
+ "file_path": file_path,
267
+ }
268
+ )
269
+
270
+ return pd.DataFrame(rows) if rows else pd.DataFrame(columns=["item_id", "sample_id", "annotator", "labels", "notes", "status", "created_at", "file_path"])
271
+
272
+
273
+ def save_draft(annotator: str, payload: Dict[str, Any]) -> Path:
274
+ path = draft_path(annotator)
275
+ path.parent.mkdir(parents=True, exist_ok=True)
276
+ with path.open("w", encoding="utf-8") as f:
277
+ json.dump(payload, f, ensure_ascii=False, indent=2)
278
+ return path
279
+
280
+
281
+ def load_draft(annotator: str) -> Dict[str, Any]:
282
+ path = draft_path(annotator)
283
+ if not path.exists():
284
+ return {}
285
+ try:
286
+ return read_json_file(path)
287
+ except Exception:
288
+ return {}
289
+
290
+
291
+ def build_labels_from_state(prefix: str = "") -> Dict[str, Any]:
292
+ return {
293
+ "distractor_kind": st.session_state.get(f"{prefix}distractor_kind", LABEL_OPTIONS["distractor_kind"][0]),
294
+ "transition_style": st.session_state.get(f"{prefix}transition_style", LABEL_OPTIONS["transition_style"][0]),
295
+ "policy_target": st.session_state.get(f"{prefix}policy_target", []),
296
+ "difficulty": int(st.session_state.get(f"{prefix}difficulty", 3)),
297
+ "realism": int(st.session_state.get(f"{prefix}realism", 3)),
298
+ "assistant_behavior": st.session_state.get(f"{prefix}assistant_behavior", LABEL_OPTIONS["assistant_behavior"][0]),
299
+ "multi_turn_escalation": bool(st.session_state.get(f"{prefix}multi_turn_escalation", False)),
300
+ "rule_followed": bool(st.session_state.get(f"{prefix}rule_followed", True)),
301
+ "needs_review": bool(st.session_state.get(f"{prefix}needs_review", False)),
302
+ "confidence": int(st.session_state.get(f"{prefix}confidence", 3)),
303
+ }
304
+
305
+
306
+ def preview_text(text: str, limit: int = 280) -> str:
307
+ txt = (text or "").strip().replace("\n", " ")
308
+ if len(txt) <= limit:
309
+ return txt
310
+ return txt[:limit - 1] + "…"
311
+
312
+
313
+ def render_turns(turns: List[Dict[str, Any]]) -> None:
314
+ if not turns:
315
+ st.info("No conversation turns found.")
316
+ return
317
+ for i, turn in enumerate(turns, 1):
318
+ role = str(turn.get("role", "unknown")).lower()
319
+ content = str(turn.get("content", "")).strip()
320
+ css_cls = "user" if role == "user" else "assistant" if role in {"assistant", "bot"} else "system"
321
+ st.markdown(
322
+ f"""
323
+ <div class="turn {css_cls}">
324
+ <span class="badge">{role.upper()}</span>
325
+ <span class="smallmono">Turn {i}</span>
326
+ <div style="margin-top:0.35rem; white-space:pre-wrap;">{content.replace(chr(10), '<br>')}</div>
327
+ </div>
328
+ """,
329
+ unsafe_allow_html=True,
330
+ )
331
+
332
+
333
+ def annotation_exists_for_item(df_anns: pd.DataFrame, item_id: str, annotator: str) -> bool:
334
+ if df_anns.empty:
335
+ return False
336
+ sub = df_anns[(df_anns["item_id"] == item_id) & (df_anns["annotator"] == annotator)]
337
+ return not sub.empty
338
+
339
+
340
+ def compute_agreement(df_anns: pd.DataFrame, label_key: str = "assistant_behavior") -> Dict[str, Any]:
341
+ if df_anns.empty:
342
+ return {"paired_items": 0, "raw_agreement": None, "cohen_kappa": None}
343
+
344
+ rows = []
345
+ for _, r in df_anns.iterrows():
346
+ labels = r.get("labels", {}) or {}
347
+ rows.append({"item_id": r["item_id"], "annotator": r["annotator"], label_key: labels.get(label_key)})
348
+ tmp = pd.DataFrame(rows)
349
+ pivot = tmp.pivot_table(index="item_id", columns="annotator", values=label_key, aggfunc="first")
350
+ pivot = pivot.dropna(axis=0, how="any")
351
+ if pivot.shape[0] < 2 or pivot.shape[1] < 2:
352
+ return {"paired_items": int(pivot.shape[0]), "raw_agreement": None, "cohen_kappa": None}
353
+
354
+ from sklearn.metrics import cohen_kappa_score
355
+
356
+ a = pivot.iloc[:, 0].astype(str)
357
+ b = pivot.iloc[:, 1].astype(str)
358
+ return {
359
+ "paired_items": int(pivot.shape[0]),
360
+ "raw_agreement": float((a == b).mean()),
361
+ "cohen_kappa": float(cohen_kappa_score(a, b)),
362
+ }
363
+
364
+
365
+ def push_annotation_to_hub(annotation_repo_id: str, payload: Dict[str, Any]) -> str:
366
+ ensure_repo_exists(annotation_repo_id)
367
+ file_rel_path = annotation_file_name(payload["item_id"], payload["annotator"])
368
+ local_path = LOCAL_DRAFT_DIR / file_rel_path.replace("/", "__")
369
+ local_path.parent.mkdir(parents=True, exist_ok=True)
370
+ with local_path.open("w", encoding="utf-8") as f:
371
+ json.dump(payload, f, ensure_ascii=False, indent=2)
372
+
373
+ api().upload_file(
374
+ path_or_fileobj=str(local_path),
375
+ path_in_repo=file_rel_path,
376
+ repo_id=annotation_repo_id,
377
+ repo_type="dataset",
378
+ token=token(),
379
+ commit_message=f"Add annotation for {payload['item_id']} by {payload['annotator']}",
380
+ )
381
+ return file_rel_path
382
+
383
+
384
+ def get_current_item_id() -> Optional[str]:
385
+ return st.session_state.get("current_item_id")
386
+
387
+
388
+ def set_current_item_id(item_id: Optional[str]) -> None:
389
+ st.session_state["current_item_id"] = item_id
390
+ try:
391
+ st.query_params["item_id"] = item_id or ""
392
+ except Exception:
393
+ pass
394
+
395
+
396
+ def main() -> None:
397
+ st.set_page_config(page_title="LLM Annotation Platform", page_icon="🧭", layout="wide")
398
+ st.markdown(
399
+ """
400
+ <style>
401
+ .block-container {padding-top: 1rem; padding-bottom: 2rem;}
402
+ .smallmono {font-size: 0.84rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;}
403
+ .cardbox {
404
+ border: 1px solid rgba(120,120,120,0.22);
405
+ border-radius: 18px;
406
+ padding: 1rem 1rem 0.75rem 1rem;
407
+ background: rgba(255,255,255,0.03);
408
+ }
409
+ .turn {
410
+ border-left: 4px solid rgba(120,120,120,0.45);
411
+ padding: 0.6rem 0.85rem;
412
+ margin: 0.55rem 0;
413
+ border-radius: 0.6rem;
414
+ background: rgba(128,128,128,0.06);
415
+ }
416
+ .turn.user {border-left-color: #8b5cf6;}
417
+ .turn.assistant, .turn.bot {border-left-color: #06b6d4;}
418
+ .turn.system {border-left-color: #f59e0b;}
419
+ .badge {
420
+ display:inline-block; padding:0.18rem 0.5rem; border-radius: 999px;
421
+ background: rgba(120,120,120,0.16); margin-right: 0.35rem; font-size: 0.78rem;
422
+ }
423
+ hr {margin: 0.7rem 0 0.9rem 0;}
424
+ </style>
425
+ """,
426
+ unsafe_allow_html=True,
427
+ )
428
+
429
+ st.title(APP_TITLE)
430
+ st.caption("A Hugging Face–native annotation tool for multi-turn distractors, inter-rater review, and dataset versioning.")
431
+
432
+ if "annotator" not in st.session_state:
433
+ st.session_state["annotator"] = "annotator_1"
434
+ if "current_item_id" not in st.session_state:
435
+ st.session_state["current_item_id"] = None
436
+ if "source_records" not in st.session_state:
437
+ st.session_state["source_records"] = None
438
+ if "source_index" not in st.session_state:
439
+ st.session_state["source_index"] = None
440
+ if "annotations_df" not in st.session_state:
441
+ st.session_state["annotations_df"] = None
442
+ if "draft_loaded" not in st.session_state:
443
+ st.session_state["draft_loaded"] = False
444
+
445
+ with st.sidebar:
446
+ st.header("Workspace")
447
+ annotator = st.text_input("Annotator name", value=st.session_state["annotator"])
448
+ st.session_state["annotator"] = annotator.strip() or "annotator_1"
449
+
450
+ source_repo = st.text_input("Source dataset repo", value=DEFAULT_SOURCE_DATASET)
451
+ source_split = st.text_input("Source split", value=DEFAULT_SOURCE_SPLIT)
452
+ annotation_repo = st.text_input("Annotation dataset repo", value=DEFAULT_ANNOTATION_REPO)
453
+
454
+ st.divider()
455
+ st.caption("HF token is needed only for upload / repo creation.")
456
+ st.write("HF token present:", "yes" if token() else "no")
457
+ st.write("Cache:", str(DEFAULT_CACHE_DIR))
458
+ st.write("Drafts:", str(LOCAL_DRAFT_DIR))
459
+
460
+ if st.button("Reload Hub data", use_container_width=True):
461
+ st.session_state["source_records"] = None
462
+ st.session_state["source_index"] = None
463
+ st.session_state["annotations_df"] = None
464
+ st.rerun()
465
+
466
+ page = st.radio("Page", ["Annotate", "Review", "Dashboard", "Export"], index=0)
467
+
468
+ if st.session_state["source_records"] is None:
469
+ with st.spinner("Loading source dataset from the Hub..."):
470
+ source_records = load_source_dataset(source_repo, source_split)
471
+ samples_df, items_df = seed_source_index(source_records)
472
+ st.session_state["source_records"] = source_records
473
+ st.session_state["source_index"] = {"samples_df": samples_df, "items_df": items_df}
474
+
475
+ if st.session_state["annotations_df"] is None:
476
+ with st.spinner("Loading annotations from the annotation dataset repo..."):
477
+ try:
478
+ anns_df = load_all_hub_annotations(annotation_repo)
479
+ except Exception as e:
480
+ anns_df = pd.DataFrame(columns=["item_id", "sample_id", "annotator", "labels", "notes", "status", "created_at", "file_path"])
481
+ st.warning(f"Could not load annotations from Hub yet: {e}")
482
+ st.session_state["annotations_df"] = anns_df
483
+
484
+ samples_df = st.session_state["source_index"]["samples_df"]
485
+ items_df = st.session_state["source_index"]["items_df"]
486
+ anns_df = st.session_state["annotations_df"]
487
+
488
+ if not st.session_state["draft_loaded"]:
489
+ try:
490
+ q_item = st.query_params.get("item_id")
491
+ except Exception:
492
+ q_item = None
493
+ if q_item:
494
+ st.session_state["current_item_id"] = q_item
495
+ draft = load_draft(st.session_state["annotator"])
496
+ if draft.get("current_item_id") and not st.session_state["current_item_id"]:
497
+ st.session_state["current_item_id"] = draft["current_item_id"]
498
+ st.session_state["draft_loaded"] = True
499
+
500
+ my_annotated_item_ids = set(
501
+ anns_df.loc[anns_df["annotator"] == st.session_state["annotator"], "item_id"].dropna().astype(str).tolist()
502
+ ) if not anns_df.empty else set()
503
+
504
+ def current_item_row() -> Optional[Dict[str, Any]]:
505
+ item_id = get_current_item_id()
506
+ if not item_id:
507
+ return None
508
+ match = items_df[items_df["item_id"] == item_id]
509
+ if match.empty:
510
+ return None
511
+ row = match.iloc[0].to_dict()
512
+ sample = samples_df[samples_df["sample_id"] == row["sample_id"]]
513
+ if not sample.empty:
514
+ row.update(sample.iloc[0].to_dict())
515
+ return row
516
+
517
+ def queue_df() -> pd.DataFrame:
518
+ return items_df[~items_df["item_id"].astype(str).isin(my_annotated_item_ids)].copy()
519
+
520
+ if page == "Annotate":
521
+ st.subheader("Annotate a distractor item")
522
+ left, right = st.columns([1.05, 0.95], gap="large")
523
+
524
+ with left:
525
+ top_a, top_b, top_c = st.columns([1, 1, 1])
526
+ with top_a:
527
+ if st.button("Claim next item", use_container_width=True):
528
+ q = queue_df()
529
+ if q.empty:
530
+ st.warning("No remaining items in your queue.")
531
+ else:
532
+ set_current_item_id(q.iloc[0]["item_id"])
533
+ st.rerun()
534
+ with top_b:
535
+ if st.button("Reload annotations from Hub", use_container_width=True):
536
+ st.session_state["annotations_df"] = load_all_hub_annotations(annotation_repo)
537
+ st.rerun()
538
+ with top_c:
539
+ if st.button("Clear current", use_container_width=True):
540
+ set_current_item_id(None)
541
+ st.rerun()
542
+
543
+ item = current_item_row()
544
+ if item is None:
545
+ st.info("Claim an item to start. The app keeps a per-annotator queue so multiple people can work in parallel.")
546
+ q = queue_df().head(10)
547
+ if not q.empty:
548
+ display = q[["item_id", "sample_id", "domain", "scenario", "distractor_index"]].copy()
549
+ display["preview"] = q["distractor_text"].map(preview_text)
550
+ st.dataframe(display, use_container_width=True, hide_index=True)
551
+ return
552
+
553
+ st.markdown(
554
+ f"""
555
+ <div class="cardbox">
556
+ <div><span class="badge">Domain</span> {item.get("domain", "")}</div>
557
+ <div style="margin-top:0.35rem;"><span class="badge">Scenario</span> {item.get("scenario", "")}</div>
558
+ <div style="margin-top:0.35rem;"><span class="badge">Sample</span> <span class="smallmono">{item.get("sample_id", "")}</span></div>
559
+ <div style="margin-top:0.35rem;"><span class="badge">Item</span> <span class="smallmono">{item.get("item_id", "")}</span></div>
560
+ </div>
561
+ """,
562
+ unsafe_allow_html=True,
563
+ )
564
+ st.divider()
565
+
566
+ tabs = st.tabs(["Context", "Distractor", "Existing annotations"])
567
+ with tabs[0]:
568
+ st.markdown("**System instruction**")
569
+ st.code(item.get("system_instruction", ""), language="text")
570
+ st.markdown("**Conversation**")
571
+ render_turns(json.loads(item.get("conversation_json", "[]")))
572
+ with tabs[1]:
573
+ st.markdown("**Previous assistant turn**")
574
+ st.code(item.get("bot_turn", "") or "(missing)", language="text")
575
+ st.markdown("**Distractor user turn**")
576
+ st.code(item.get("distractor_text", "") or "(missing)", language="text")
577
+ with tabs[2]:
578
+ existing = anns_df[anns_df["item_id"] == item["item_id"]].copy()
579
+ if existing.empty:
580
+ st.caption("No annotations yet.")
581
+ else:
582
+ for _, row in existing.iterrows():
583
+ st.write(f"**{row['annotator']}** · {row['status']} · {row['created_at']}")
584
+ st.json(row["labels"])
585
+ if row.get("notes"):
586
+ st.caption(row["notes"])
587
+ st.divider()
588
+
589
+ with right:
590
+ st.markdown("### Annotation form")
591
+ current_draft = load_draft(st.session_state["annotator"])
592
+ draft_labels = current_draft.get("labels", {}) if current_draft else {}
593
+
594
+ with st.form("annotation_form", clear_on_submit=False):
595
+ st.selectbox(
596
+ "Distractor kind",
597
+ LABEL_OPTIONS["distractor_kind"],
598
+ index=LABEL_OPTIONS["distractor_kind"].index(draft_labels.get("distractor_kind", LABEL_OPTIONS["distractor_kind"][0]))
599
+ if draft_labels.get("distractor_kind") in LABEL_OPTIONS["distractor_kind"]
600
+ else 0,
601
+ key="distractor_kind",
602
+ )
603
+ st.selectbox(
604
+ "Transition style",
605
+ LABEL_OPTIONS["transition_style"],
606
+ index=LABEL_OPTIONS["transition_style"].index(draft_labels.get("transition_style", LABEL_OPTIONS["transition_style"][0]))
607
+ if draft_labels.get("transition_style") in LABEL_OPTIONS["transition_style"]
608
+ else 0,
609
+ key="transition_style",
610
+ )
611
+ st.multiselect(
612
+ "Policy target(s)",
613
+ LABEL_OPTIONS["policy_target"],
614
+ default=draft_labels.get("policy_target", []),
615
+ key="policy_target",
616
+ )
617
+ c1, c2 = st.columns(2)
618
+ with c1:
619
+ st.slider("Difficulty", 1, 5, value=int(draft_labels.get("difficulty", 3)), key="difficulty")
620
+ st.slider("Realism", 1, 5, value=int(draft_labels.get("realism", 3)), key="realism")
621
+ with c2:
622
+ st.selectbox(
623
+ "Assistant behavior",
624
+ LABEL_OPTIONS["assistant_behavior"],
625
+ index=LABEL_OPTIONS["assistant_behavior"].index(draft_labels.get("assistant_behavior", LABEL_OPTIONS["assistant_behavior"][0]))
626
+ if draft_labels.get("assistant_behavior") in LABEL_OPTIONS["assistant_behavior"]
627
+ else 0,
628
+ key="assistant_behavior",
629
+ )
630
+ st.slider("Confidence", 1, 5, value=int(draft_labels.get("confidence", 3)), key="confidence")
631
+
632
+ st.checkbox(
633
+ "Multi-turn escalation / persistence",
634
+ value=bool(draft_labels.get("multi_turn_escalation", False)),
635
+ key="multi_turn_escalation",
636
+ )
637
+ st.checkbox(
638
+ "Assistant followed the rule",
639
+ value=bool(draft_labels.get("rule_followed", True)),
640
+ key="rule_followed",
641
+ )
642
+ st.checkbox(
643
+ "Borderline / needs review",
644
+ value=bool(draft_labels.get("needs_review", False)),
645
+ key="needs_review",
646
+ )
647
+ notes = st.text_area(
648
+ "Notes",
649
+ value=current_draft.get("notes", ""),
650
+ height=150,
651
+ placeholder="Explain ambiguity, likely disagreement, or policy edge cases.",
652
+ )
653
+ submitted = st.form_submit_button("Submit to Hugging Face", use_container_width=True)
654
+
655
+ c1, c2 = st.columns(2)
656
+ with c1:
657
+ if st.button("Save draft locally", use_container_width=True):
658
+ payload = {
659
+ "current_item_id": item["item_id"],
660
+ "labels": build_labels_from_state(),
661
+ "notes": notes,
662
+ "saved_at": now_iso(),
663
+ }
664
+ path = save_draft(st.session_state["annotator"], payload)
665
+ st.success(f"Draft saved to {path}")
666
+ with c2:
667
+ if st.button("Sync annotation cache", use_container_width=True):
668
+ st.session_state["annotations_df"] = load_all_hub_annotations(annotation_repo)
669
+ st.success("Reloaded annotation index from Hub.")
670
+
671
+ if submitted:
672
+ labels = build_labels_from_state()
673
+ payload = {
674
+ "annotation_id": str(uuid.uuid4()),
675
+ "item_id": item["item_id"],
676
+ "sample_id": item["sample_id"],
677
+ "annotator": st.session_state["annotator"],
678
+ "created_at": now_iso(),
679
+ "status": "submitted",
680
+ "labels": labels,
681
+ "notes": notes,
682
+ "source": {
683
+ "source_dataset_repo": source_repo,
684
+ "source_dataset_split": source_split,
685
+ "domain": item.get("domain", ""),
686
+ "scenario": item.get("scenario", ""),
687
+ "distractor_index": int(item.get("distractor_index", 0)),
688
+ },
689
+ }
690
+ try:
691
+ path_in_repo = push_annotation_to_hub(annotation_repo, payload)
692
+ st.session_state["annotations_df"] = pd.concat(
693
+ [
694
+ anns_df,
695
+ pd.DataFrame(
696
+ [
697
+ {
698
+ "item_id": payload["item_id"],
699
+ "sample_id": payload["sample_id"],
700
+ "annotator": payload["annotator"],
701
+ "labels": payload["labels"],
702
+ "notes": payload["notes"],
703
+ "status": payload["status"],
704
+ "created_at": payload["created_at"],
705
+ "file_path": path_in_repo,
706
+ }
707
+ ]
708
+ ),
709
+ ],
710
+ ignore_index=True,
711
+ )
712
+ save_draft(
713
+ st.session_state["annotator"],
714
+ {
715
+ "current_item_id": item["item_id"],
716
+ "labels": labels,
717
+ "notes": notes,
718
+ "saved_at": now_iso(),
719
+ },
720
+ )
721
+ st.success(f"Submitted to Hugging Face as {path_in_repo}")
722
+ q = queue_df()
723
+ if not q.empty:
724
+ set_current_item_id(q.iloc[0]["item_id"])
725
+ st.rerun()
726
+ except Exception as e:
727
+ st.error(f"Upload failed. Saved locally only. Error: {e}")
728
+ save_draft(
729
+ st.session_state["annotator"],
730
+ {
731
+ "current_item_id": item["item_id"],
732
+ "labels": labels,
733
+ "notes": notes,
734
+ "saved_at": now_iso(),
735
+ },
736
+ )
737
+
738
+ st.caption("Each submission is a separate file in the annotation dataset repo, so multiple annotators can work in parallel without write conflicts.")
739
+
740
+ elif page == "Review":
741
+ st.subheader("Inter-rater review")
742
+ multi = (
743
+ anns_df.groupby("item_id")["annotator"].nunique().reset_index(name="n_annotators")
744
+ if not anns_df.empty
745
+ else pd.DataFrame(columns=["item_id", "n_annotators"])
746
+ )
747
+ multi = multi[multi["n_annotators"] >= 2] if not multi.empty else multi
748
+
749
+ if multi.empty:
750
+ st.info("No items with at least two annotations yet.")
751
+ else:
752
+ selected_item = st.selectbox("Item with multiple annotations", multi["item_id"].tolist())
753
+ row = items_df[items_df["item_id"] == selected_item].iloc[0].to_dict()
754
+ sample = samples_df[samples_df["sample_id"] == row["sample_id"]].iloc[0].to_dict()
755
+ row.update(sample)
756
+
757
+ st.markdown("### Context")
758
+ st.code(row["system_instruction"], language="text")
759
+ st.code(row["bot_turn"] or "", language="text")
760
+ st.code(row["distractor_text"] or "", language="text")
761
+
762
+ st.markdown("### Annotations")
763
+ sub = anns_df[anns_df["item_id"] == selected_item].copy()
764
+ cols = st.columns(min(len(sub), 3)) if len(sub) > 0 else st.columns(1)
765
+ for idx, (_, ann) in enumerate(sub.iterrows()):
766
+ with cols[idx % len(cols)]:
767
+ st.write(f"**{ann['annotator']}**")
768
+ st.caption(f"{ann['status']} · {ann['created_at']}")
769
+ st.json(ann["labels"])
770
+ if ann.get("notes"):
771
+ st.caption(ann["notes"])
772
+
773
+ agreement = compute_agreement(sub, label_key="assistant_behavior")
774
+ c1, c2, c3 = st.columns(3)
775
+ c1.metric("Paired items", agreement["paired_items"])
776
+ c2.metric("Raw agreement", f"{agreement['raw_agreement']:.2%}" if agreement["raw_agreement"] is not None else "n/a")
777
+ c3.metric("Cohen's κ", f"{agreement['cohen_kappa']:.3f}" if agreement["cohen_kappa"] is not None else "n/a")
778
+
779
+ elif page == "Dashboard":
780
+ st.subheader("Dashboard")
781
+ c1, c2, c3, c4 = st.columns(4)
782
+ c1.metric("Source samples", len(samples_df))
783
+ c2.metric("Source items", len(items_df))
784
+ c3.metric("Annotation files", len(anns_df))
785
+ c4.metric("My queue", len(queue_df()))
786
+
787
+ st.markdown("### Progress by annotator")
788
+ if anns_df.empty:
789
+ st.info("No annotations yet.")
790
+ else:
791
+ by_ann = anns_df.groupby("annotator")["item_id"].nunique().reset_index(name="annotated_items").sort_values("annotated_items", ascending=False)
792
+ st.dataframe(by_ann, use_container_width=True, hide_index=True)
793
+
794
+ st.markdown("### Progress by domain")
795
+ joined = anns_df.merge(items_df[["item_id", "domain"]], on="item_id", how="left")
796
+ by_domain = joined.groupby("domain")["item_id"].nunique().reset_index(name="annotated_items").sort_values("annotated_items", ascending=False)
797
+ st.dataframe(by_domain, use_container_width=True, hide_index=True)
798
+
799
+ st.markdown("### Agreement snapshot")
800
+ metric = compute_agreement(anns_df, label_key="assistant_behavior")
801
+ st.write(metric)
802
+
803
+ st.markdown("### Recent annotation previews")
804
+ recent = anns_df.sort_values("created_at", ascending=False).head(20).copy()
805
+ if "labels" in recent.columns:
806
+ recent["assistant_behavior"] = recent["labels"].apply(lambda x: x.get("assistant_behavior") if isinstance(x, dict) else None)
807
+ recent["distractor_kind"] = recent["labels"].apply(lambda x: x.get("distractor_kind") if isinstance(x, dict) else None)
808
+ st.dataframe(
809
+ recent[["annotator", "item_id", "status", "created_at", "assistant_behavior", "distractor_kind", "notes"]],
810
+ use_container_width=True,
811
+ hide_index=True,
812
+ )
813
+
814
+ else:
815
+ st.subheader("Export")
816
+ st.write("Export the merged dataset for downstream analysis or model training.")
817
+
818
+ merged = items_df.merge(samples_df, on="sample_id", how="left")
819
+ if not anns_df.empty:
820
+ export_df = merged.merge(anns_df[["item_id", "annotator", "labels", "notes", "status", "created_at"]], on="item_id", how="left")
821
+ else:
822
+ export_df = merged.copy()
823
+ export_df["annotator"] = None
824
+ export_df["labels"] = None
825
+ export_df["notes"] = None
826
+ export_df["status"] = None
827
+ export_df["created_at"] = None
828
+
829
+ c1, c2 = st.columns(2)
830
+ with c1:
831
+ jsonl = LOCAL_EXPORT_DIR / "annotations_export.jsonl"
832
+ if st.button("Generate JSONL export", use_container_width=True):
833
+ with jsonl.open("w", encoding="utf-8") as f:
834
+ for _, r in export_df.iterrows():
835
+ f.write(json.dumps(r.where(pd.notna(r), None).to_dict(), ensure_ascii=False) + "\n")
836
+ st.success(f"Wrote {jsonl}")
837
+ st.download_button("Download JSONL", jsonl.read_text(encoding="utf-8"), file_name=jsonl.name, mime="application/json")
838
+ with c2:
839
+ csv = LOCAL_EXPORT_DIR / "annotations_export.csv"
840
+ if st.button("Generate CSV export", use_container_width=True):
841
+ export_df.to_csv(csv, index=False)
842
+ st.success(f"Wrote {csv}")
843
+ st.download_button("Download CSV", csv.read_text(encoding="utf-8"), file_name=csv.name, mime="text/csv")
844
+
845
+ st.markdown("### Repository handoff")
846
+ st.code(
847
+ f"Source repo: {source_repo}\nAnnotation repo: {annotation_repo}\nSplit: {source_split}\nAnnotator: {st.session_state['annotator']}",
848
+ language="text",
849
+ )
850
+
851
+
852
+ if __name__ == "__main__":
853
+ main()
hf-space/hf-space/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
hf-space/hf-space/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Llm Annotation Platform
3
+ emoji: 🦀
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
hf-space/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ streamlit>=1.37
2
+ pandas>=2.2
3
+ datasets>=2.21
4
+ huggingface_hub>=0.24
5
+ scikit-learn>=1.5
hf-space/scripts/seed.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from datasets import load_dataset
6
+
7
+ from app import DEFAULT_ANNOTATION_REPO, DEFAULT_SOURCE_DATASET, DEFAULT_SOURCE_SPLIT
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser()
12
+ parser.add_argument("--source", default=DEFAULT_SOURCE_DATASET)
13
+ parser.add_argument("--split", default=DEFAULT_SOURCE_SPLIT)
14
+ parser.add_argument("--annotation-repo", default=DEFAULT_ANNOTATION_REPO)
15
+ parser.add_argument("--limit", type=int, default=0)
16
+ args = parser.parse_args()
17
+
18
+ records = load_dataset(args.source, split=args.split)
19
+ if args.limit:
20
+ records = records.select(range(min(len(records), args.limit)))
21
+
22
+ print(f"Loaded {len(records)} source records from {args.source}/{args.split}")
23
+ print(f"Annotation repo: {args.annotation_repo}")
24
+ print("Open the Streamlit app and submit annotations there.")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()