Spaces:
Running
Running
Store each review as a separate file under reviews/<submission>/
Browse files- Submissions are now immutable; reviews live in their own files, so
concurrent reviews by different people never conflict (no read-modify-write).
- add_review() writes reviews/<base>/<stamp>__<reviewer>.json.
- list_submissions() derives current status from the latest review and bundles
the full review timeline + submission record per item (single repo listing).
- Admin page reads bundled data; shows timeline + 'Add a review' form.
- README documents the new submissions/ + reviews/ layout and Python loader.
- README.md +43 -14
- lib/storage.py +131 -144
- pages/1_Admin.py +25 -33
README.md
CHANGED
|
@@ -23,7 +23,7 @@ A Streamlit intake form for trial statisticians. Submissions are saved to a **Hu
|
|
| 23 |
- `extraction_only` → 1 rubric: `output.json`
|
| 24 |
- `derivation_required` → 4 rubrics: `output.json` × {Inputs used, Calculated value, Method} + `output.R` × {Reproducibility}
|
| 25 |
- Each rubric collects `points`, `tolerance`, `criterion`.
|
| 26 |
-
- **Admin page (`pages/1_Admin.py`)** — password-gated review console. A submission can be reviewed many times by different people: each review (status + reviewer name + comment) is
|
| 27 |
|
| 28 |
## Run locally
|
| 29 |
|
|
@@ -85,9 +85,20 @@ The Space will restart automatically and pick up the new secrets.
|
|
| 85 |
### 6. Test
|
| 86 |
|
| 87 |
- Open the Space URL → fill the form → **Submit**. A new file lands in `submissions/<trial_id>__<username>__<timestamp>.json` in the dataset repo.
|
| 88 |
-
- Open the **Admin** page (left sidebar) → enter password → see the submission with status `pending` →
|
| 89 |
|
| 90 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
```json
|
| 93 |
{
|
|
@@ -95,14 +106,6 @@ The Space will restart automatically and pick up the new secrets.
|
|
| 95 |
"submittedAt": "2026-06-01T...",
|
| 96 |
"trial_id": "NCT0001",
|
| 97 |
"username": "jdoe",
|
| 98 |
-
"status": "needs_fix",
|
| 99 |
-
"reviewer": "Dr. Lee",
|
| 100 |
-
"reviewerNote": "still missing the power assumption",
|
| 101 |
-
"reviewedAt": "2026-06-01T16:00:00+00:00",
|
| 102 |
-
"review_history": [
|
| 103 |
-
{"at": "2026-06-01T15:30:00+00:00", "reviewer": "Dr. Smith", "status": "reviewed", "note": "looks good"},
|
| 104 |
-
{"at": "2026-06-01T16:00:00+00:00", "reviewer": "Dr. Lee", "status": "needs_fix", "note": "still missing the power assumption"}
|
| 105 |
-
],
|
| 106 |
"comparison": {
|
| 107 |
"trial_id": "NCT0001",
|
| 108 |
"username": "jdoe",
|
|
@@ -125,14 +128,40 @@ The Space will restart automatically and pick up the new secrets.
|
|
| 125 |
}
|
| 126 |
```
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
```python
|
| 131 |
from huggingface_hub import snapshot_download
|
| 132 |
-
import json, glob
|
| 133 |
|
| 134 |
local = snapshot_download("ttt-77/tdb-intake-submissions", repo_type="dataset")
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
```
|
| 137 |
|
| 138 |
## Project structure
|
|
|
|
| 23 |
- `extraction_only` → 1 rubric: `output.json`
|
| 24 |
- `derivation_required` → 4 rubrics: `output.json` × {Inputs used, Calculated value, Method} + `output.R` × {Reproducibility}
|
| 25 |
- Each rubric collects `points`, `tolerance`, `criterion`.
|
| 26 |
+
- **Admin page (`pages/1_Admin.py`)** — password-gated review console. A submission can be reviewed many times by different people: each review (status + reviewer name + comment) is written as its own file under `reviews/<submission>/`, and the page shows the full timeline. The current status is the most recent review's status. Submissions themselves are never modified.
|
| 27 |
|
| 28 |
## Run locally
|
| 29 |
|
|
|
|
| 85 |
### 6. Test
|
| 86 |
|
| 87 |
- Open the Space URL → fill the form → **Submit**. A new file lands in `submissions/<trial_id>__<username>__<timestamp>.json` in the dataset repo.
|
| 88 |
+
- Open the **Admin** page (left sidebar) → enter password → see the submission with status `pending` → add a review (your name + status + comment). It appears in the review timeline and a new file lands under `reviews/<submission>/`. Add more reviews to build up the history.
|
| 89 |
|
| 90 |
+
## Dataset layout
|
| 91 |
+
|
| 92 |
+
Submissions are **immutable**. Each review is a **separate file** — so a
|
| 93 |
+
submission can be reviewed many times by different people, and concurrent
|
| 94 |
+
reviews never conflict (each is a brand-new file, never an overwrite).
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
submissions/<trial>__<user>__<stamp>.json # the submission (never rewritten)
|
| 98 |
+
reviews/<trial>__<user>__<stamp>/<stamp>__<rev>.json # one file per review
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### Submission file (`submissions/*.json`)
|
| 102 |
|
| 103 |
```json
|
| 104 |
{
|
|
|
|
| 106 |
"submittedAt": "2026-06-01T...",
|
| 107 |
"trial_id": "NCT0001",
|
| 108 |
"username": "jdoe",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
"comparison": {
|
| 110 |
"trial_id": "NCT0001",
|
| 111 |
"username": "jdoe",
|
|
|
|
| 128 |
}
|
| 129 |
```
|
| 130 |
|
| 131 |
+
### Review file (`reviews/<submission>/*.json`)
|
| 132 |
+
|
| 133 |
+
```json
|
| 134 |
+
{
|
| 135 |
+
"submissionId": "submissions/NCT0001__jdoe__2026-06-01T...Z.json",
|
| 136 |
+
"at": "2026-06-01T16:00:00+00:00",
|
| 137 |
+
"reviewer": "Dr. Lee",
|
| 138 |
+
"status": "needs_fix",
|
| 139 |
+
"note": "still missing the power assumption"
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
The **current status** of a submission is derived as the most recent review's
|
| 144 |
+
status (or `pending` if it has no reviews yet).
|
| 145 |
+
|
| 146 |
+
### Load everything in Python
|
| 147 |
|
| 148 |
```python
|
| 149 |
from huggingface_hub import snapshot_download
|
| 150 |
+
import json, glob, os
|
| 151 |
|
| 152 |
local = snapshot_download("ttt-77/tdb-intake-submissions", repo_type="dataset")
|
| 153 |
+
|
| 154 |
+
submissions = {
|
| 155 |
+
os.path.basename(f)[:-5]: json.load(open(f))
|
| 156 |
+
for f in glob.glob(f"{local}/submissions/*.json")
|
| 157 |
+
}
|
| 158 |
+
# reviews grouped by submission base name
|
| 159 |
+
reviews = {}
|
| 160 |
+
for f in glob.glob(f"{local}/reviews/*/*.json"):
|
| 161 |
+
base = os.path.basename(os.path.dirname(f))
|
| 162 |
+
reviews.setdefault(base, []).append(json.load(open(f)))
|
| 163 |
+
for base in reviews:
|
| 164 |
+
reviews[base].sort(key=lambda r: r["at"]) # oldest first
|
| 165 |
```
|
| 166 |
|
| 167 |
## Project structure
|
lib/storage.py
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
"""Storage backend: Hugging Face Dataset repo, with local filesystem fallback for dev.
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Env vars (set in HF Space → Settings → Variables and secrets):
|
| 4 |
HF_TOKEN - HF user access token with Write permission
|
| 5 |
HF_DATASET_REPO - e.g. "ttt-77/tdb-intake-submissions"
|
| 6 |
HF_DATASET_BRANCH - optional, defaults to "main"
|
| 7 |
-
ADMIN_PASSWORD - shared password for the
|
| 8 |
|
| 9 |
-
If HF_TOKEN or HF_DATASET_REPO is missing, all I/O goes to ./data/
|
| 10 |
-
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -35,6 +44,7 @@ _api = HfApi(token=HF_TOKEN) if hf_configured else None
|
|
| 35 |
|
| 36 |
LOCAL_DATA_DIR = Path("data")
|
| 37 |
SUBMISSIONS_PREFIX = "submissions"
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def _safe(s: str) -> str:
|
|
@@ -45,11 +55,17 @@ def _now_iso() -> str:
|
|
| 45 |
return datetime.now(timezone.utc).isoformat()
|
| 46 |
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
return _now_iso().replace(":", "-").replace(".", "-")
|
|
|
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
|
| 55 |
assert _api is not None
|
|
@@ -65,182 +81,153 @@ def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message:
|
|
| 65 |
|
| 66 |
|
| 67 |
def _hf_read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
|
| 68 |
-
"""Fetch via the resolve URL — no cache, always fresh."""
|
| 69 |
url = (
|
| 70 |
f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
|
| 71 |
f"/resolve/{HF_DATASET_BRANCH}/{path_in_repo}"
|
| 72 |
)
|
| 73 |
-
|
| 74 |
-
r = requests.get(url, headers=headers, timeout=20)
|
| 75 |
if r.status_code == 404:
|
| 76 |
return None
|
| 77 |
r.raise_for_status()
|
| 78 |
return r.json()
|
| 79 |
|
| 80 |
|
| 81 |
-
def
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
revision=HF_DATASET_BRANCH,
|
| 88 |
-
)
|
| 89 |
-
except HfHubHTTPError as e:
|
| 90 |
-
if e.response is not None and e.response.status_code == 404:
|
| 91 |
-
return []
|
| 92 |
-
raise
|
| 93 |
-
return [
|
| 94 |
-
f for f in files
|
| 95 |
-
if f.startswith(f"{SUBMISSIONS_PREFIX}/") and f.endswith(".json")
|
| 96 |
-
]
|
| 97 |
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
def create_submission(trial_id: str, username: str, comparison: Dict[str, Any]) -> Dict[str, Any]:
|
| 102 |
-
"""Write a new submission. Returns
|
| 103 |
-
file_name = f"{_safe(trial_id)}__{_safe(username)}__{
|
| 104 |
submission_id = f"{SUBMISSIONS_PREFIX}/{file_name}"
|
| 105 |
record = {
|
| 106 |
"submissionId": submission_id,
|
| 107 |
"submittedAt": _now_iso(),
|
| 108 |
"trial_id": trial_id,
|
| 109 |
"username": username,
|
| 110 |
-
# These top-level fields mirror the most recent review for easy
|
| 111 |
-
# filtering/sorting; they are updated by add_review().
|
| 112 |
-
"status": "pending",
|
| 113 |
-
"reviewer": "",
|
| 114 |
-
"reviewerNote": "",
|
| 115 |
-
"reviewedAt": "",
|
| 116 |
-
# Full append-only log: one entry per review by any reviewer.
|
| 117 |
-
"review_history": [],
|
| 118 |
"comparison": comparison,
|
| 119 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
if hf_configured:
|
| 122 |
-
_hf_upload_json(
|
| 123 |
-
submission_id,
|
| 124 |
-
record,
|
| 125 |
-
commit_message=f"Add submission: {trial_id} — {username}",
|
| 126 |
-
)
|
| 127 |
-
return {
|
| 128 |
-
"submissionId": submission_id,
|
| 129 |
-
"url": (
|
| 130 |
-
f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
|
| 131 |
-
f"/blob/{HF_DATASET_BRANCH}/{submission_id}"
|
| 132 |
-
),
|
| 133 |
-
"record": record,
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
# local fs fallback
|
| 137 |
-
path = LOCAL_DATA_DIR / submission_id
|
| 138 |
-
path.parent.mkdir(parents=True, exist_ok=True)
|
| 139 |
-
path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 140 |
-
return {"submissionId": submission_id, "url": None, "record": record}
|
| 141 |
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
def get_submission(submission_id: str) -> Optional[Dict[str, Any]]:
|
| 169 |
if not submission_id.startswith(f"{SUBMISSIONS_PREFIX}/"):
|
| 170 |
return None
|
| 171 |
-
|
| 172 |
-
return _hf_read_json(submission_id)
|
| 173 |
-
path = LOCAL_DATA_DIR / submission_id
|
| 174 |
-
if not path.exists():
|
| 175 |
-
return None
|
| 176 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 177 |
|
| 178 |
|
| 179 |
-
def
|
| 180 |
-
|
| 181 |
-
status: str,
|
| 182 |
-
reviewer: str,
|
| 183 |
-
note: str = "",
|
| 184 |
-
) -> Optional[Dict[str, Any]]:
|
| 185 |
-
"""Append a review to the submission's history.
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
status/reviewer/reviewerNote/reviewedAt fields (which always reflect the
|
| 190 |
-
latest review).
|
| 191 |
"""
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
_hf_upload_json(
|
| 217 |
-
submission_id,
|
| 218 |
-
record,
|
| 219 |
-
commit_message=f"Review ({status}) by {reviewer or 'anon'}: "
|
| 220 |
-
f"{submission_id.rsplit('/', 1)[-1]}",
|
| 221 |
)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 225 |
-
return record
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
def _summarize(record: Dict[str, Any]) -> Dict[str, Any]:
|
| 229 |
-
history = record.get("review_history")
|
| 230 |
-
review_count = len(history) if isinstance(history, list) else 0
|
| 231 |
-
return {
|
| 232 |
-
"submissionId": record.get("submissionId", ""),
|
| 233 |
-
"trial_id": record.get("trial_id", ""),
|
| 234 |
-
"username": record.get("username", ""),
|
| 235 |
-
"submittedAt": record.get("submittedAt", ""),
|
| 236 |
-
"status": record.get("status", "pending"),
|
| 237 |
-
"reviewedAt": record.get("reviewedAt", ""),
|
| 238 |
-
"reviewer": record.get("reviewer", ""),
|
| 239 |
-
"review_count": review_count,
|
| 240 |
-
}
|
| 241 |
|
| 242 |
|
| 243 |
-
# ----
|
| 244 |
|
| 245 |
def check_admin_password(supplied: str) -> bool:
|
| 246 |
if not ADMIN_PASSWORD:
|
|
|
|
| 1 |
"""Storage backend: Hugging Face Dataset repo, with local filesystem fallback for dev.
|
| 2 |
|
| 3 |
+
Layout inside the dataset repo:
|
| 4 |
+
|
| 5 |
+
submissions/<trial>__<user>__<stamp>.json (immutable: the submission)
|
| 6 |
+
reviews/<trial>__<user>__<stamp>/<stamp>__<rev>.json (one file per review)
|
| 7 |
+
|
| 8 |
+
Submissions are never rewritten. Each review is a brand-new file, so multiple
|
| 9 |
+
reviewers can review the same submission concurrently with no write conflict.
|
| 10 |
+
The "current status" of a submission is derived from its most recent review.
|
| 11 |
+
|
| 12 |
Env vars (set in HF Space → Settings → Variables and secrets):
|
| 13 |
HF_TOKEN - HF user access token with Write permission
|
| 14 |
HF_DATASET_REPO - e.g. "ttt-77/tdb-intake-submissions"
|
| 15 |
HF_DATASET_BRANCH - optional, defaults to "main"
|
| 16 |
+
ADMIN_PASSWORD - shared password for the Admin page
|
| 17 |
|
| 18 |
+
If HF_TOKEN or HF_DATASET_REPO is missing, all I/O goes to ./data/... so local
|
| 19 |
+
dev works without HF credentials.
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
|
|
| 44 |
|
| 45 |
LOCAL_DATA_DIR = Path("data")
|
| 46 |
SUBMISSIONS_PREFIX = "submissions"
|
| 47 |
+
REVIEWS_PREFIX = "reviews"
|
| 48 |
|
| 49 |
|
| 50 |
def _safe(s: str) -> str:
|
|
|
|
| 55 |
return datetime.now(timezone.utc).isoformat()
|
| 56 |
|
| 57 |
|
| 58 |
+
def _stamp(iso: Optional[str] = None) -> str:
|
| 59 |
+
return (iso or _now_iso()).replace(":", "-").replace(".", "-")
|
| 60 |
+
|
| 61 |
|
| 62 |
+
def _base_id(submission_id: str) -> str:
|
| 63 |
+
"""'submissions/foo.json' -> 'foo'"""
|
| 64 |
+
name = submission_id.split("/")[-1]
|
| 65 |
+
return name[:-5] if name.endswith(".json") else name
|
| 66 |
|
| 67 |
+
|
| 68 |
+
# ---- low-level read/write/list (HF or local) -----------------------------
|
| 69 |
|
| 70 |
def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
|
| 71 |
assert _api is not None
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def _hf_read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
|
|
|
|
| 84 |
url = (
|
| 85 |
f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
|
| 86 |
f"/resolve/{HF_DATASET_BRANCH}/{path_in_repo}"
|
| 87 |
)
|
| 88 |
+
r = requests.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"}, timeout=20)
|
|
|
|
| 89 |
if r.status_code == 404:
|
| 90 |
return None
|
| 91 |
r.raise_for_status()
|
| 92 |
return r.json()
|
| 93 |
|
| 94 |
|
| 95 |
+
def _write_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
|
| 96 |
+
if hf_configured:
|
| 97 |
+
_hf_upload_json(path_in_repo, payload, commit_message)
|
| 98 |
+
return
|
| 99 |
+
p = LOCAL_DATA_DIR / path_in_repo
|
| 100 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
p.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
|
| 105 |
+
if hf_configured:
|
| 106 |
+
return _hf_read_json(path_in_repo)
|
| 107 |
+
p = LOCAL_DATA_DIR / path_in_repo
|
| 108 |
+
if not p.exists():
|
| 109 |
+
return None
|
| 110 |
try:
|
| 111 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 112 |
+
except Exception:
|
| 113 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
+
def _all_files() -> List[str]:
|
| 117 |
+
"""List every file path in the repo (HF) or under ./data (local)."""
|
| 118 |
+
if hf_configured:
|
| 119 |
+
assert _api is not None
|
| 120 |
+
try:
|
| 121 |
+
return _api.list_repo_files(
|
| 122 |
+
repo_id=HF_DATASET_REPO,
|
| 123 |
+
repo_type="dataset",
|
| 124 |
+
revision=HF_DATASET_BRANCH,
|
| 125 |
+
)
|
| 126 |
+
except HfHubHTTPError as e:
|
| 127 |
+
if e.response is not None and e.response.status_code == 404:
|
| 128 |
+
return []
|
| 129 |
+
raise
|
| 130 |
+
if not LOCAL_DATA_DIR.exists():
|
| 131 |
+
return []
|
| 132 |
+
return [p.relative_to(LOCAL_DATA_DIR).as_posix() for p in LOCAL_DATA_DIR.rglob("*.json")]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---- public API ----------------------------------------------------------
|
| 136 |
|
| 137 |
def create_submission(trial_id: str, username: str, comparison: Dict[str, Any]) -> Dict[str, Any]:
|
| 138 |
+
"""Write a new (immutable) submission file. Returns submissionId + url."""
|
| 139 |
+
file_name = f"{_safe(trial_id)}__{_safe(username)}__{_stamp()}.json"
|
| 140 |
submission_id = f"{SUBMISSIONS_PREFIX}/{file_name}"
|
| 141 |
record = {
|
| 142 |
"submissionId": submission_id,
|
| 143 |
"submittedAt": _now_iso(),
|
| 144 |
"trial_id": trial_id,
|
| 145 |
"username": username,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
"comparison": comparison,
|
| 147 |
}
|
| 148 |
+
_write_json(submission_id, record, f"Add submission: {trial_id} — {username}")
|
| 149 |
+
url = (
|
| 150 |
+
f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
|
| 151 |
+
f"/blob/{HF_DATASET_BRANCH}/{submission_id}"
|
| 152 |
+
if hf_configured
|
| 153 |
+
else None
|
| 154 |
+
)
|
| 155 |
+
return {"submissionId": submission_id, "url": url, "record": record}
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
+
def add_review(submission_id: str, status: str, reviewer: str, note: str = "") -> Dict[str, Any]:
|
| 159 |
+
"""Append a review as its own file under reviews/<base>/.
|
| 160 |
|
| 161 |
+
Each review is a new file (never overwrites), so concurrent reviews by
|
| 162 |
+
different people cannot conflict.
|
| 163 |
+
"""
|
| 164 |
+
base = _base_id(submission_id)
|
| 165 |
+
now = _now_iso()
|
| 166 |
+
review = {
|
| 167 |
+
"submissionId": submission_id,
|
| 168 |
+
"at": now,
|
| 169 |
+
"reviewer": reviewer,
|
| 170 |
+
"status": status,
|
| 171 |
+
"note": note,
|
| 172 |
+
}
|
| 173 |
+
review_path = f"{REVIEWS_PREFIX}/{base}/{_stamp(now)}__{_safe(reviewer) or 'anon'}.json"
|
| 174 |
+
_write_json(review_path, review, f"Review ({status}) by {reviewer or 'anon'} on {base}")
|
| 175 |
+
return review
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def list_reviews(submission_id: str, all_files: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
| 179 |
+
"""All reviews for a submission, oldest first."""
|
| 180 |
+
base = _base_id(submission_id)
|
| 181 |
+
prefix = f"{REVIEWS_PREFIX}/{base}/"
|
| 182 |
+
files = all_files if all_files is not None else _all_files()
|
| 183 |
+
paths = sorted(f for f in files if f.startswith(prefix) and f.endswith(".json"))
|
| 184 |
+
reviews = [r for r in (_read_json(p) for p in paths) if r]
|
| 185 |
+
reviews.sort(key=lambda r: r.get("at", ""))
|
| 186 |
+
return reviews
|
| 187 |
|
| 188 |
|
| 189 |
def get_submission(submission_id: str) -> Optional[Dict[str, Any]]:
|
| 190 |
if not submission_id.startswith(f"{SUBMISSIONS_PREFIX}/"):
|
| 191 |
return None
|
| 192 |
+
return _read_json(submission_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
+
def list_submissions() -> List[Dict[str, Any]]:
|
| 196 |
+
"""Every submission with its derived status and full review timeline.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
Each item: submissionId, trial_id, username, submittedAt, status,
|
| 199 |
+
reviewedAt, reviewer, review_count, reviews (list), submission (full record).
|
|
|
|
|
|
|
| 200 |
"""
|
| 201 |
+
files = _all_files()
|
| 202 |
+
sub_paths = sorted(
|
| 203 |
+
f for f in files if f.startswith(f"{SUBMISSIONS_PREFIX}/") and f.endswith(".json")
|
| 204 |
+
)
|
| 205 |
+
result: List[Dict[str, Any]] = []
|
| 206 |
+
for sp in sub_paths:
|
| 207 |
+
sub = _read_json(sp)
|
| 208 |
+
if not sub:
|
| 209 |
+
continue
|
| 210 |
+
reviews = list_reviews(sp, all_files=files)
|
| 211 |
+
latest = reviews[-1] if reviews else None
|
| 212 |
+
result.append(
|
| 213 |
+
{
|
| 214 |
+
"submissionId": sp,
|
| 215 |
+
"trial_id": sub.get("trial_id", ""),
|
| 216 |
+
"username": sub.get("username", ""),
|
| 217 |
+
"submittedAt": sub.get("submittedAt", ""),
|
| 218 |
+
"status": latest["status"] if latest else "pending",
|
| 219 |
+
"reviewedAt": latest["at"] if latest else "",
|
| 220 |
+
"reviewer": latest["reviewer"] if latest else "",
|
| 221 |
+
"review_count": len(reviews),
|
| 222 |
+
"reviews": reviews,
|
| 223 |
+
"submission": sub,
|
| 224 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
)
|
| 226 |
+
result.sort(key=lambda r: r.get("submittedAt", ""), reverse=True)
|
| 227 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
+
# ---- admin gate ----------------------------------------------------------
|
| 231 |
|
| 232 |
def check_admin_password(supplied: str) -> bool:
|
| 233 |
if not ADMIN_PASSWORD:
|
pages/1_Admin.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
"""Admin review console — list submissions, view review
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -16,7 +16,6 @@ from lib.storage import (
|
|
| 16 |
ADMIN_PASSWORD,
|
| 17 |
add_review,
|
| 18 |
check_admin_password,
|
| 19 |
-
get_submission,
|
| 20 |
hf_configured,
|
| 21 |
list_submissions,
|
| 22 |
)
|
|
@@ -59,7 +58,7 @@ if not st.session_state.admin_authed:
|
|
| 59 |
# ------------- load list -------------------------------------------------
|
| 60 |
|
| 61 |
if not hf_configured:
|
| 62 |
-
st.info("ℹ️ Reading from `./data/
|
| 63 |
|
| 64 |
cols = st.columns([3, 1])
|
| 65 |
with cols[0]:
|
|
@@ -74,23 +73,23 @@ with cols[1]:
|
|
| 74 |
st.rerun()
|
| 75 |
|
| 76 |
try:
|
| 77 |
-
|
| 78 |
except Exception as e:
|
| 79 |
st.error(f"Failed to list submissions: {e}")
|
| 80 |
st.stop()
|
| 81 |
|
| 82 |
if status_filter:
|
| 83 |
-
|
| 84 |
|
| 85 |
-
st.caption(f"{len(
|
| 86 |
|
| 87 |
-
if not
|
| 88 |
st.info("No submissions match this filter.")
|
| 89 |
st.stop()
|
| 90 |
|
| 91 |
# ------------- list display ----------------------------------------------
|
| 92 |
|
| 93 |
-
for s in
|
| 94 |
n_reviews = s.get("review_count", 0)
|
| 95 |
review_tag = f" · 💬 {n_reviews}" if n_reviews else " · no reviews yet"
|
| 96 |
label = (
|
|
@@ -99,32 +98,26 @@ for s in summaries:
|
|
| 99 |
f"_{s['submittedAt']}_{review_tag}"
|
| 100 |
)
|
| 101 |
with st.expander(label):
|
| 102 |
-
record = get_submission(s["submissionId"])
|
| 103 |
-
if record is None:
|
| 104 |
-
st.error("Could not fetch this record.")
|
| 105 |
-
continue
|
| 106 |
-
|
| 107 |
meta_c1, meta_c2 = st.columns(2)
|
| 108 |
with meta_c1:
|
| 109 |
st.markdown(
|
| 110 |
-
f"**Current status:** {status_badge(
|
| 111 |
-
f"**Submitted:** {
|
| 112 |
-
f"**Last reviewed:** {
|
| 113 |
)
|
| 114 |
with meta_c2:
|
| 115 |
st.markdown(
|
| 116 |
-
f"**File:** `{
|
| 117 |
-
f"**Last reviewer:** {
|
| 118 |
)
|
| 119 |
|
| 120 |
-
# ---- Review
|
| 121 |
-
|
| 122 |
-
st.markdown(f"#### Review history ({len(
|
| 123 |
-
if not
|
| 124 |
st.caption("No reviews yet.")
|
| 125 |
else:
|
| 126 |
-
#
|
| 127 |
-
for rev in reversed(history):
|
| 128 |
st.markdown(
|
| 129 |
f"- {status_badge(rev.get('status', ''))} — "
|
| 130 |
f"**{rev.get('reviewer') or 'anon'}** "
|
|
@@ -132,14 +125,14 @@ for s in summaries:
|
|
| 132 |
+ (f" \n {rev.get('note')}" if rev.get("note") else "")
|
| 133 |
)
|
| 134 |
|
| 135 |
-
# ---- Add a
|
| 136 |
st.markdown("#### Add a review")
|
| 137 |
with st.form(f"review_{s['submissionId']}"):
|
| 138 |
new_status = st.radio(
|
| 139 |
"Status",
|
| 140 |
options=VALID_STATUSES,
|
| 141 |
-
index=VALID_STATUSES.index(
|
| 142 |
-
if
|
| 143 |
else 0,
|
| 144 |
horizontal=True,
|
| 145 |
)
|
|
@@ -148,8 +141,7 @@ for s in summaries:
|
|
| 148 |
reviewer = st.text_input("Your name", placeholder="e.g., Dr. Smith")
|
| 149 |
with rc2:
|
| 150 |
note = st.text_input("Comment", placeholder="optional")
|
| 151 |
-
|
| 152 |
-
if submitted:
|
| 153 |
if not reviewer.strip():
|
| 154 |
st.error("Please enter your name.")
|
| 155 |
else:
|
|
@@ -166,4 +158,4 @@ for s in summaries:
|
|
| 166 |
st.error(f"Failed to add review: {e}")
|
| 167 |
|
| 168 |
with st.expander("Raw submission JSON"):
|
| 169 |
-
st.code(json.dumps(
|
|
|
|
| 1 |
+
"""Admin review console — list submissions, view review timeline, add reviews.
|
| 2 |
|
| 3 |
+
Submissions are immutable. Each review is stored as its own file under
|
| 4 |
+
``reviews/<submission>/``; a submission can be reviewed many times by different
|
| 5 |
+
people. The "current status" shown here is the most recent review's status.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
|
|
| 16 |
ADMIN_PASSWORD,
|
| 17 |
add_review,
|
| 18 |
check_admin_password,
|
|
|
|
| 19 |
hf_configured,
|
| 20 |
list_submissions,
|
| 21 |
)
|
|
|
|
| 58 |
# ------------- load list -------------------------------------------------
|
| 59 |
|
| 60 |
if not hf_configured:
|
| 61 |
+
st.info("ℹ️ Reading from `./data/` (local dev mode).")
|
| 62 |
|
| 63 |
cols = st.columns([3, 1])
|
| 64 |
with cols[0]:
|
|
|
|
| 73 |
st.rerun()
|
| 74 |
|
| 75 |
try:
|
| 76 |
+
items = list_submissions()
|
| 77 |
except Exception as e:
|
| 78 |
st.error(f"Failed to list submissions: {e}")
|
| 79 |
st.stop()
|
| 80 |
|
| 81 |
if status_filter:
|
| 82 |
+
items = [s for s in items if s["status"] in status_filter]
|
| 83 |
|
| 84 |
+
st.caption(f"{len(items)} submission(s)")
|
| 85 |
|
| 86 |
+
if not items:
|
| 87 |
st.info("No submissions match this filter.")
|
| 88 |
st.stop()
|
| 89 |
|
| 90 |
# ------------- list display ----------------------------------------------
|
| 91 |
|
| 92 |
+
for s in items:
|
| 93 |
n_reviews = s.get("review_count", 0)
|
| 94 |
review_tag = f" · 💬 {n_reviews}" if n_reviews else " · no reviews yet"
|
| 95 |
label = (
|
|
|
|
| 98 |
f"_{s['submittedAt']}_{review_tag}"
|
| 99 |
)
|
| 100 |
with st.expander(label):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
meta_c1, meta_c2 = st.columns(2)
|
| 102 |
with meta_c1:
|
| 103 |
st.markdown(
|
| 104 |
+
f"**Current status:** {status_badge(s.get('status', 'pending'))} \n"
|
| 105 |
+
f"**Submitted:** {s.get('submittedAt', '')} \n"
|
| 106 |
+
f"**Last reviewed:** {s.get('reviewedAt', '') or '—'}"
|
| 107 |
)
|
| 108 |
with meta_c2:
|
| 109 |
st.markdown(
|
| 110 |
+
f"**File:** `{s.get('submissionId', '')}` \n"
|
| 111 |
+
f"**Last reviewer:** {s.get('reviewer', '') or '—'}"
|
| 112 |
)
|
| 113 |
|
| 114 |
+
# ---- Review timeline -----------------------------------------
|
| 115 |
+
reviews = s.get("reviews") or []
|
| 116 |
+
st.markdown(f"#### Review history ({len(reviews)})")
|
| 117 |
+
if not reviews:
|
| 118 |
st.caption("No reviews yet.")
|
| 119 |
else:
|
| 120 |
+
for rev in reversed(reviews): # newest first
|
|
|
|
| 121 |
st.markdown(
|
| 122 |
f"- {status_badge(rev.get('status', ''))} — "
|
| 123 |
f"**{rev.get('reviewer') or 'anon'}** "
|
|
|
|
| 125 |
+ (f" \n {rev.get('note')}" if rev.get("note") else "")
|
| 126 |
)
|
| 127 |
|
| 128 |
+
# ---- Add a review --------------------------------------------
|
| 129 |
st.markdown("#### Add a review")
|
| 130 |
with st.form(f"review_{s['submissionId']}"):
|
| 131 |
new_status = st.radio(
|
| 132 |
"Status",
|
| 133 |
options=VALID_STATUSES,
|
| 134 |
+
index=VALID_STATUSES.index(s.get("status", "pending"))
|
| 135 |
+
if s.get("status") in VALID_STATUSES
|
| 136 |
else 0,
|
| 137 |
horizontal=True,
|
| 138 |
)
|
|
|
|
| 141 |
reviewer = st.text_input("Your name", placeholder="e.g., Dr. Smith")
|
| 142 |
with rc2:
|
| 143 |
note = st.text_input("Comment", placeholder="optional")
|
| 144 |
+
if st.form_submit_button("Add review", type="primary"):
|
|
|
|
| 145 |
if not reviewer.strip():
|
| 146 |
st.error("Please enter your name.")
|
| 147 |
else:
|
|
|
|
| 158 |
st.error(f"Failed to add review: {e}")
|
| 159 |
|
| 160 |
with st.expander("Raw submission JSON"):
|
| 161 |
+
st.code(json.dumps(s.get("submission", {}), indent=2, ensure_ascii=False), language="json")
|