Spaces:
Sleeping
Sleeping
Switch storage to Hugging Face Dataset; add /admin review console
Browse files- README.md +64 -41
- app/admin/page.tsx +288 -0
- app/api/submissions/[...path]/route.ts +64 -0
- app/api/submissions/route.ts +18 -0
- app/layout.tsx +8 -3
- lib/storage.ts +222 -104
README.md
CHANGED
|
@@ -1,14 +1,11 @@
|
|
| 1 |
# Clinical Trial AI Reproduction Benchmark — Intake
|
| 2 |
|
| 3 |
-
A Next.js + Tailwind
|
| 4 |
|
| 5 |
-
|
| 6 |
-
- A list of questions, each with `design_element`, `question_type` (`extraction_only` / `derivation_required`), and an Evaluation block (`artifact`, `dimension`, `points`, `criterion`, `tolerance`).
|
| 7 |
|
| 8 |
-
Buttons:
|
| 9 |
-
|
| 10 |
-
- **Save draft** — persists current form to `localStorage`.
|
| 11 |
-
- **Submit** — opens a GitHub issue and commits the raw JSON file to the repo.
|
| 12 |
|
| 13 |
## Run locally
|
| 14 |
|
|
@@ -18,73 +15,99 @@ npm run dev
|
|
| 18 |
# open http://localhost:3000
|
| 19 |
```
|
| 20 |
|
| 21 |
-
Without
|
| 22 |
-
|
| 23 |
-
## Deploy on Vercel + store submissions on GitHub
|
| 24 |
-
|
| 25 |
-
### 1. Create a private submissions repo
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
- commit the raw JSON file to `submissions/<trial_id>__<username>__<timestamp>.json` in the same repo.
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
- **
|
| 37 |
-
- **
|
| 38 |
-
|
| 39 |
-
- **Issues:** Read and write (for the issue).
|
| 40 |
-
|
| 41 |
-
Save the token — you'll paste it into Vercel next.
|
| 42 |
|
| 43 |
### 3. Push this repo to GitHub and import on Vercel
|
| 44 |
|
| 45 |
```bash
|
| 46 |
-
git
|
| 47 |
-
git push -u origin main
|
| 48 |
```
|
| 49 |
|
| 50 |
Then go to <https://vercel.com>, import the repo (auto-detects Next.js).
|
| 51 |
|
| 52 |
### 4. Add env vars in Vercel
|
| 53 |
|
| 54 |
-
Project → Settings → Environment Variables (set for Production + Preview):
|
| 55 |
|
| 56 |
-
| Name | Value |
|
| 57 |
-
| --- | --- |
|
| 58 |
-
| `
|
| 59 |
-
| `
|
| 60 |
-
| `
|
|
|
|
| 61 |
|
| 62 |
Redeploy after saving.
|
| 63 |
|
| 64 |
### 5. Test
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
- A new issue titled `[Intake] <trial_id> — <username>`, labeled `intake-submission`, linking to that file.
|
| 70 |
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
## Privacy notes
|
| 74 |
|
| 75 |
-
- The
|
| 76 |
-
- `
|
| 77 |
-
- Rotate the
|
|
|
|
| 78 |
|
| 79 |
## Project structure
|
| 80 |
|
| 81 |
```text
|
| 82 |
app/
|
| 83 |
-
layout.tsx, page.tsx, globals.css
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
components/
|
| 86 |
StepCompare.tsx
|
| 87 |
lib/
|
| 88 |
types.ts, storage.ts
|
| 89 |
-
data/submissions/
|
| 90 |
```
|
|
|
|
| 1 |
# Clinical Trial AI Reproduction Benchmark — Intake
|
| 2 |
|
| 3 |
+
A Next.js + Tailwind intake form for trial statisticians. Submissions are saved to a **Hugging Face Dataset** repo. An `/admin` page lets reviewers triage submissions (pending / reviewed / needs_fix).
|
| 4 |
|
| 5 |
+
## What it does
|
|
|
|
| 6 |
|
| 7 |
+
- **Public form (`/`)** — statisticians enter `trial_id`, `username`, and a list of questions. Each question has `design_element` (dropdown), `question_type` (dropdown), and an auto-generated rubric block. Buttons: **Save draft** (localStorage), **Submit** (writes to HF Dataset).
|
| 8 |
+
- **Admin (`/admin`)** — password-gated review console. Lists every submission with its current status, lets you change status, add a reviewer name + note. Updates are committed back to the dataset.
|
|
|
|
|
|
|
| 9 |
|
| 10 |
## Run locally
|
| 11 |
|
|
|
|
| 15 |
# open http://localhost:3000
|
| 16 |
```
|
| 17 |
|
| 18 |
+
Without HF env vars set, submissions land in `./data/submissions/<...>.json` on disk — fine for dev.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
## Deploy on Vercel + store on Hugging Face
|
| 21 |
|
| 22 |
+
### 1. Create a private HF Dataset repo
|
|
|
|
| 23 |
|
| 24 |
+
- Sign in at <https://huggingface.co>
|
| 25 |
+
- Click your avatar → **New Dataset**
|
| 26 |
+
- Owner: your username (e.g. `ttt-77`)
|
| 27 |
+
- Name: e.g. `tdb-intake-submissions`
|
| 28 |
+
- Visibility: **Private**
|
| 29 |
+
- Create. Leave it empty — files will appear as submissions arrive.
|
| 30 |
|
| 31 |
+
### 2. Generate an HF access token
|
| 32 |
|
| 33 |
+
- <https://huggingface.co/settings/tokens> → **New token**
|
| 34 |
+
- Type: **Write**
|
| 35 |
+
- Save the `hf_...` string.
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
### 3. Push this repo to GitHub and import on Vercel
|
| 38 |
|
| 39 |
```bash
|
| 40 |
+
git push
|
|
|
|
| 41 |
```
|
| 42 |
|
| 43 |
Then go to <https://vercel.com>, import the repo (auto-detects Next.js).
|
| 44 |
|
| 45 |
### 4. Add env vars in Vercel
|
| 46 |
|
| 47 |
+
Project → Settings → Environment Variables (set for Production + Preview + Development):
|
| 48 |
|
| 49 |
+
| Name | Value | Required |
|
| 50 |
+
| --- | --- | --- |
|
| 51 |
+
| `HF_TOKEN` | the token from step 2 | ✅ |
|
| 52 |
+
| `HF_DATASET_REPO` | `ttt-77/tdb-intake-submissions` | ✅ |
|
| 53 |
+
| `HF_DATASET_BRANCH` | `main` (default) | optional |
|
| 54 |
+
| `ADMIN_PASSWORD` | a password you'll give to reviewers | ✅ for `/admin` |
|
| 55 |
|
| 56 |
Redeploy after saving.
|
| 57 |
|
| 58 |
### 5. Test
|
| 59 |
|
| 60 |
+
- Open the deployed URL, fill the form, **Submit**. A new file appears in the HF dataset at `submissions/<trial_id>__<username>__<timestamp>.json`.
|
| 61 |
+
- Open `/admin`, enter the password, you'll see the new submission with status `pending`. Click to expand, set reviewer/note, change status — every change becomes a commit on the dataset.
|
| 62 |
+
|
| 63 |
+
## Submission record shape
|
| 64 |
+
|
| 65 |
+
Each `submissions/*.json` file looks like:
|
| 66 |
+
|
| 67 |
+
```json
|
| 68 |
+
{
|
| 69 |
+
"submissionId": "submissions/NCT0001__jdoe__2026-06-01T...Z.json",
|
| 70 |
+
"submittedAt": "2026-06-01T...Z",
|
| 71 |
+
"trial_id": "NCT0001",
|
| 72 |
+
"username": "jdoe",
|
| 73 |
+
"status": "pending",
|
| 74 |
+
"reviewer": "",
|
| 75 |
+
"reviewerNote": "",
|
| 76 |
+
"reviewedAt": "...",
|
| 77 |
+
"comparison": { ... full form payload ... }
|
| 78 |
+
}
|
| 79 |
+
```
|
| 80 |
|
| 81 |
+
Load all submissions in Python:
|
|
|
|
| 82 |
|
| 83 |
+
```python
|
| 84 |
+
from huggingface_hub import HfApi
|
| 85 |
+
api = HfApi()
|
| 86 |
+
# Either clone the dataset:
|
| 87 |
+
# from huggingface_hub import snapshot_download
|
| 88 |
+
# snapshot_download("ttt-77/tdb-intake-submissions", repo_type="dataset")
|
| 89 |
+
# Or list the files via API and read each.
|
| 90 |
+
```
|
| 91 |
|
| 92 |
## Privacy notes
|
| 93 |
|
| 94 |
+
- The dataset repo should be **private**.
|
| 95 |
+
- `HF_TOKEN` lives only in Vercel env vars — never commit it.
|
| 96 |
+
- Rotate the token periodically; update the env var and redeploy.
|
| 97 |
+
- `ADMIN_PASSWORD` is a shared secret — anyone with it can change submission statuses.
|
| 98 |
|
| 99 |
## Project structure
|
| 100 |
|
| 101 |
```text
|
| 102 |
app/
|
| 103 |
+
layout.tsx, page.tsx, globals.css — public form
|
| 104 |
+
admin/page.tsx — review console
|
| 105 |
+
api/submit/route.ts POST — create submission
|
| 106 |
+
api/submissions/route.ts GET — list submissions (admin)
|
| 107 |
+
api/submissions/[...path]/route.ts GET, PATCH — one submission (admin)
|
| 108 |
components/
|
| 109 |
StepCompare.tsx
|
| 110 |
lib/
|
| 111 |
types.ts, storage.ts
|
| 112 |
+
data/submissions/ — created at runtime in dev only
|
| 113 |
```
|
app/admin/page.tsx
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 3 |
+
|
| 4 |
+
type Status = "pending" | "reviewed" | "needs_fix";
|
| 5 |
+
|
| 6 |
+
type Summary = {
|
| 7 |
+
submissionId: string;
|
| 8 |
+
trial_id: string;
|
| 9 |
+
username: string;
|
| 10 |
+
submittedAt: string;
|
| 11 |
+
status: Status;
|
| 12 |
+
reviewedAt?: string;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
type Detail = Summary & {
|
| 16 |
+
reviewer?: string;
|
| 17 |
+
reviewerNote?: string;
|
| 18 |
+
comparison: unknown;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const statusColor = (s: Status) =>
|
| 22 |
+
s === "reviewed"
|
| 23 |
+
? "bg-emerald-100 text-emerald-800 border-emerald-200"
|
| 24 |
+
: s === "needs_fix"
|
| 25 |
+
? "bg-rose-100 text-rose-800 border-rose-200"
|
| 26 |
+
: "bg-amber-100 text-amber-800 border-amber-200";
|
| 27 |
+
|
| 28 |
+
const PW_KEY = "admin-pw";
|
| 29 |
+
|
| 30 |
+
export default function AdminPage() {
|
| 31 |
+
const [pw, setPw] = useState("");
|
| 32 |
+
const [authed, setAuthed] = useState(false);
|
| 33 |
+
const [items, setItems] = useState<Summary[]>([]);
|
| 34 |
+
const [filter, setFilter] = useState<"all" | Status>("all");
|
| 35 |
+
const [openId, setOpenId] = useState<string | null>(null);
|
| 36 |
+
const [detail, setDetail] = useState<Detail | null>(null);
|
| 37 |
+
const [loading, setLoading] = useState(false);
|
| 38 |
+
const [error, setError] = useState<string | null>(null);
|
| 39 |
+
const [reviewer, setReviewer] = useState("");
|
| 40 |
+
const [reviewerNote, setReviewerNote] = useState("");
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const stored = sessionStorage.getItem(PW_KEY) || "";
|
| 44 |
+
if (stored) {
|
| 45 |
+
setPw(stored);
|
| 46 |
+
setAuthed(true);
|
| 47 |
+
}
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const headers = useMemo(
|
| 51 |
+
() => ({ "Content-Type": "application/json", "x-admin-password": pw }),
|
| 52 |
+
[pw],
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
const refresh = useCallback(async () => {
|
| 56 |
+
setLoading(true);
|
| 57 |
+
setError(null);
|
| 58 |
+
try {
|
| 59 |
+
const res = await fetch("/api/submissions", { headers });
|
| 60 |
+
if (res.status === 401) {
|
| 61 |
+
setAuthed(false);
|
| 62 |
+
sessionStorage.removeItem(PW_KEY);
|
| 63 |
+
throw new Error("Wrong password.");
|
| 64 |
+
}
|
| 65 |
+
const json = await res.json();
|
| 66 |
+
if (!json.ok) throw new Error(json.error || "List failed");
|
| 67 |
+
setItems(json.items);
|
| 68 |
+
} catch (e: any) {
|
| 69 |
+
setError(e?.message || "List failed");
|
| 70 |
+
} finally {
|
| 71 |
+
setLoading(false);
|
| 72 |
+
}
|
| 73 |
+
}, [headers]);
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
if (authed) refresh();
|
| 77 |
+
}, [authed, refresh]);
|
| 78 |
+
|
| 79 |
+
const openDetail = async (id: string) => {
|
| 80 |
+
if (openId === id) {
|
| 81 |
+
setOpenId(null);
|
| 82 |
+
setDetail(null);
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
setOpenId(id);
|
| 86 |
+
setDetail(null);
|
| 87 |
+
setError(null);
|
| 88 |
+
try {
|
| 89 |
+
const res = await fetch(`/api/${id}`, { headers });
|
| 90 |
+
const json = await res.json();
|
| 91 |
+
if (!json.ok) throw new Error(json.error || "Fetch failed");
|
| 92 |
+
setDetail(json.record);
|
| 93 |
+
setReviewer(json.record.reviewer || "");
|
| 94 |
+
setReviewerNote(json.record.reviewerNote || "");
|
| 95 |
+
} catch (e: any) {
|
| 96 |
+
setError(e?.message || "Fetch failed");
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const setStatus = async (id: string, status: Status) => {
|
| 101 |
+
setError(null);
|
| 102 |
+
try {
|
| 103 |
+
const res = await fetch(`/api/${id}`, {
|
| 104 |
+
method: "PATCH",
|
| 105 |
+
headers,
|
| 106 |
+
body: JSON.stringify({ status, reviewer, reviewerNote }),
|
| 107 |
+
});
|
| 108 |
+
const json = await res.json();
|
| 109 |
+
if (!json.ok) throw new Error(json.error || "Update failed");
|
| 110 |
+
setDetail(json.record);
|
| 111 |
+
setItems((prev) =>
|
| 112 |
+
prev.map((p) =>
|
| 113 |
+
p.submissionId === id ? { ...p, status, reviewedAt: json.record.reviewedAt } : p,
|
| 114 |
+
),
|
| 115 |
+
);
|
| 116 |
+
} catch (e: any) {
|
| 117 |
+
setError(e?.message || "Update failed");
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const filtered = filter === "all" ? items : items.filter((i) => i.status === filter);
|
| 122 |
+
|
| 123 |
+
if (!authed) {
|
| 124 |
+
return (
|
| 125 |
+
<div className="card max-w-sm mx-auto space-y-3">
|
| 126 |
+
<h2 className="text-base font-semibold">Admin login</h2>
|
| 127 |
+
<input
|
| 128 |
+
type="password"
|
| 129 |
+
className="input"
|
| 130 |
+
placeholder="Admin password"
|
| 131 |
+
value={pw}
|
| 132 |
+
onChange={(e) => setPw(e.target.value)}
|
| 133 |
+
onKeyDown={(e) => {
|
| 134 |
+
if (e.key === "Enter") {
|
| 135 |
+
sessionStorage.setItem(PW_KEY, pw);
|
| 136 |
+
setAuthed(true);
|
| 137 |
+
}
|
| 138 |
+
}}
|
| 139 |
+
/>
|
| 140 |
+
<button
|
| 141 |
+
className="btn-primary w-full"
|
| 142 |
+
onClick={() => {
|
| 143 |
+
sessionStorage.setItem(PW_KEY, pw);
|
| 144 |
+
setAuthed(true);
|
| 145 |
+
}}
|
| 146 |
+
>
|
| 147 |
+
Continue
|
| 148 |
+
</button>
|
| 149 |
+
{error && (
|
| 150 |
+
<div className="text-xs text-rose-700">{error}</div>
|
| 151 |
+
)}
|
| 152 |
+
<p className="text-xs text-slate-500">
|
| 153 |
+
If <code>ADMIN_PASSWORD</code> is unset on the server, any value (including empty) is accepted.
|
| 154 |
+
</p>
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div className="space-y-4">
|
| 161 |
+
<div className="flex items-center justify-between flex-wrap gap-2">
|
| 162 |
+
<h2 className="text-base font-semibold">Submissions ({items.length})</h2>
|
| 163 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 164 |
+
{(["all", "pending", "reviewed", "needs_fix"] as const).map((s) => (
|
| 165 |
+
<button
|
| 166 |
+
key={s}
|
| 167 |
+
onClick={() => setFilter(s)}
|
| 168 |
+
className={
|
| 169 |
+
"px-2.5 py-1 rounded text-xs border " +
|
| 170 |
+
(filter === s
|
| 171 |
+
? "bg-slate-900 text-white border-slate-900"
|
| 172 |
+
: "bg-white text-slate-700 border-slate-300 hover:bg-slate-100")
|
| 173 |
+
}
|
| 174 |
+
>
|
| 175 |
+
{s}
|
| 176 |
+
</button>
|
| 177 |
+
))}
|
| 178 |
+
<button className="btn-secondary !py-1 !text-xs" onClick={refresh} disabled={loading}>
|
| 179 |
+
{loading ? "Refreshing…" : "Refresh"}
|
| 180 |
+
</button>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{error && (
|
| 185 |
+
<div className="rounded-md bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-900">
|
| 186 |
+
{error}
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
{filtered.length === 0 && !loading && (
|
| 191 |
+
<div className="text-sm text-slate-500">No submissions match this filter.</div>
|
| 192 |
+
)}
|
| 193 |
+
|
| 194 |
+
<ul className="space-y-2">
|
| 195 |
+
{filtered.map((it) => {
|
| 196 |
+
const isOpen = openId === it.submissionId;
|
| 197 |
+
return (
|
| 198 |
+
<li key={it.submissionId} className="card !p-0 overflow-hidden">
|
| 199 |
+
<button
|
| 200 |
+
className="w-full flex items-center justify-between gap-3 p-3 text-left hover:bg-slate-50"
|
| 201 |
+
onClick={() => openDetail(it.submissionId)}
|
| 202 |
+
>
|
| 203 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 204 |
+
<span
|
| 205 |
+
className={
|
| 206 |
+
"px-2 py-0.5 rounded text-xs border font-medium " + statusColor(it.status)
|
| 207 |
+
}
|
| 208 |
+
>
|
| 209 |
+
{it.status}
|
| 210 |
+
</span>
|
| 211 |
+
<div className="truncate">
|
| 212 |
+
<div className="text-sm font-medium truncate">
|
| 213 |
+
{it.trial_id}{" "}
|
| 214 |
+
<span className="text-slate-500 font-normal">— {it.username}</span>
|
| 215 |
+
</div>
|
| 216 |
+
<div className="text-xs text-slate-500 truncate">
|
| 217 |
+
{it.submittedAt}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
<span className="text-xs text-slate-400">{isOpen ? "▲" : "▼"}</span>
|
| 222 |
+
</button>
|
| 223 |
+
|
| 224 |
+
{isOpen && (
|
| 225 |
+
<div className="border-t border-slate-200 p-4 bg-slate-50 space-y-3">
|
| 226 |
+
{!detail && <div className="text-xs text-slate-500">Loading…</div>}
|
| 227 |
+
{detail && (
|
| 228 |
+
<>
|
| 229 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 230 |
+
<div>
|
| 231 |
+
<label className="label">Reviewer</label>
|
| 232 |
+
<input
|
| 233 |
+
className="input"
|
| 234 |
+
value={reviewer}
|
| 235 |
+
onChange={(e) => setReviewer(e.target.value)}
|
| 236 |
+
placeholder="your name"
|
| 237 |
+
/>
|
| 238 |
+
</div>
|
| 239 |
+
<div>
|
| 240 |
+
<label className="label">Reviewer note</label>
|
| 241 |
+
<input
|
| 242 |
+
className="input"
|
| 243 |
+
value={reviewerNote}
|
| 244 |
+
onChange={(e) => setReviewerNote(e.target.value)}
|
| 245 |
+
/>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 249 |
+
<span className="text-xs text-slate-500 mr-1">Set status:</span>
|
| 250 |
+
{(["pending", "reviewed", "needs_fix"] as const).map((s) => (
|
| 251 |
+
<button
|
| 252 |
+
key={s}
|
| 253 |
+
className={
|
| 254 |
+
"px-2.5 py-1 rounded text-xs border " +
|
| 255 |
+
(detail.status === s
|
| 256 |
+
? statusColor(s)
|
| 257 |
+
: "bg-white text-slate-700 border-slate-300 hover:bg-slate-100")
|
| 258 |
+
}
|
| 259 |
+
onClick={() => setStatus(it.submissionId, s)}
|
| 260 |
+
>
|
| 261 |
+
{s}
|
| 262 |
+
</button>
|
| 263 |
+
))}
|
| 264 |
+
{detail.reviewedAt && (
|
| 265 |
+
<span className="text-xs text-slate-500 ml-2">
|
| 266 |
+
last updated {detail.reviewedAt}
|
| 267 |
+
</span>
|
| 268 |
+
)}
|
| 269 |
+
</div>
|
| 270 |
+
<details className="text-xs">
|
| 271 |
+
<summary className="cursor-pointer text-slate-600">
|
| 272 |
+
Raw submission JSON
|
| 273 |
+
</summary>
|
| 274 |
+
<pre className="mt-2 p-3 rounded bg-white border border-slate-200 overflow-x-auto text-[11px]">
|
| 275 |
+
{JSON.stringify(detail, null, 2)}
|
| 276 |
+
</pre>
|
| 277 |
+
</details>
|
| 278 |
+
</>
|
| 279 |
+
)}
|
| 280 |
+
</div>
|
| 281 |
+
)}
|
| 282 |
+
</li>
|
| 283 |
+
);
|
| 284 |
+
})}
|
| 285 |
+
</ul>
|
| 286 |
+
</div>
|
| 287 |
+
);
|
| 288 |
+
}
|
app/api/submissions/[...path]/route.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
SubmissionStatus,
|
| 4 |
+
getSubmission,
|
| 5 |
+
isAdminAuthorized,
|
| 6 |
+
updateSubmission,
|
| 7 |
+
} from "@/lib/storage";
|
| 8 |
+
|
| 9 |
+
const VALID_STATUSES: SubmissionStatus[] = ["pending", "reviewed", "needs_fix"];
|
| 10 |
+
|
| 11 |
+
function buildId(params: { path: string[] }): string {
|
| 12 |
+
// The catch-all route gives us segments like ["submissions","foo.json"].
|
| 13 |
+
// We prepend "submissions/" only if it isn't already the first segment.
|
| 14 |
+
const parts = params.path;
|
| 15 |
+
return parts[0] === "submissions" ? parts.join("/") : `submissions/${parts.join("/")}`;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function GET(
|
| 19 |
+
req: Request,
|
| 20 |
+
{ params }: { params: { path: string[] } },
|
| 21 |
+
) {
|
| 22 |
+
if (!isAdminAuthorized(req)) {
|
| 23 |
+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
| 24 |
+
}
|
| 25 |
+
const id = buildId(params);
|
| 26 |
+
const record = await getSubmission(id);
|
| 27 |
+
if (!record) {
|
| 28 |
+
return NextResponse.json({ ok: false, error: "not found" }, { status: 404 });
|
| 29 |
+
}
|
| 30 |
+
return NextResponse.json({ ok: true, record });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function PATCH(
|
| 34 |
+
req: Request,
|
| 35 |
+
{ params }: { params: { path: string[] } },
|
| 36 |
+
) {
|
| 37 |
+
if (!isAdminAuthorized(req)) {
|
| 38 |
+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
| 39 |
+
}
|
| 40 |
+
const id = buildId(params);
|
| 41 |
+
const body = await req.json().catch(() => ({}));
|
| 42 |
+
const patch: { status?: SubmissionStatus; reviewer?: string; reviewerNote?: string } = {};
|
| 43 |
+
if (body.status) {
|
| 44 |
+
if (!VALID_STATUSES.includes(body.status)) {
|
| 45 |
+
return NextResponse.json({ ok: false, error: "invalid status" }, { status: 400 });
|
| 46 |
+
}
|
| 47 |
+
patch.status = body.status;
|
| 48 |
+
}
|
| 49 |
+
if (typeof body.reviewer === "string") patch.reviewer = body.reviewer;
|
| 50 |
+
if (typeof body.reviewerNote === "string") patch.reviewerNote = body.reviewerNote;
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const updated = await updateSubmission(id, patch);
|
| 54 |
+
if (!updated) {
|
| 55 |
+
return NextResponse.json({ ok: false, error: "not found" }, { status: 404 });
|
| 56 |
+
}
|
| 57 |
+
return NextResponse.json({ ok: true, record: updated });
|
| 58 |
+
} catch (e: any) {
|
| 59 |
+
return NextResponse.json(
|
| 60 |
+
{ ok: false, error: e?.message || "Update failed" },
|
| 61 |
+
{ status: 500 },
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
}
|
app/api/submissions/route.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { isAdminAuthorized, listSubmissions } from "@/lib/storage";
|
| 3 |
+
|
| 4 |
+
export async function GET(req: Request) {
|
| 5 |
+
if (!isAdminAuthorized(req)) {
|
| 6 |
+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
| 7 |
+
}
|
| 8 |
+
try {
|
| 9 |
+
const items = await listSubmissions();
|
| 10 |
+
items.sort((a, b) => (a.submittedAt < b.submittedAt ? 1 : -1));
|
| 11 |
+
return NextResponse.json({ ok: true, items });
|
| 12 |
+
} catch (e: any) {
|
| 13 |
+
return NextResponse.json(
|
| 14 |
+
{ ok: false, error: e?.message || "List failed" },
|
| 15 |
+
{ status: 500 },
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
}
|
app/layout.tsx
CHANGED
|
@@ -12,9 +12,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
| 12 |
<body>
|
| 13 |
<div className="min-h-screen">
|
| 14 |
<header className="border-b border-slate-200 bg-white">
|
| 15 |
-
<div className="mx-auto max-w-4xl px-6 py-4">
|
| 16 |
-
<
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
</header>
|
| 20 |
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
|
|
|
|
| 12 |
<body>
|
| 13 |
<div className="min-h-screen">
|
| 14 |
<header className="border-b border-slate-200 bg-white">
|
| 15 |
+
<div className="mx-auto max-w-4xl px-6 py-4 flex items-center justify-between">
|
| 16 |
+
<div>
|
| 17 |
+
<h1 className="text-lg font-semibold">Clinical Trial AI Reproduction Benchmark</h1>
|
| 18 |
+
<p className="text-xs text-slate-500">Statistician intake & evaluation form</p>
|
| 19 |
+
</div>
|
| 20 |
+
<a href="/admin" className="text-xs text-slate-500 hover:text-slate-900 underline">
|
| 21 |
+
Admin
|
| 22 |
+
</a>
|
| 23 |
</div>
|
| 24 |
</header>
|
| 25 |
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
|
lib/storage.ts
CHANGED
|
@@ -1,42 +1,40 @@
|
|
| 1 |
-
// Storage backend:
|
| 2 |
//
|
| 3 |
-
// In production set
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
| 7 |
//
|
| 8 |
-
// If
|
|
|
|
| 9 |
|
| 10 |
import { promises as fs } from "fs";
|
| 11 |
import path from "path";
|
| 12 |
|
| 13 |
-
const
|
| 14 |
-
const
|
| 15 |
-
const
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
const safe = (s: string) => (s || "").trim().replace(/[^a-zA-Z0-9-_]/g, "_");
|
| 19 |
|
| 20 |
-
|
| 21 |
-
const res = await fetch(`https://api.github.com${pathname}`, {
|
| 22 |
-
...init,
|
| 23 |
-
cache: "no-store",
|
| 24 |
-
headers: {
|
| 25 |
-
Accept: "application/vnd.github+json",
|
| 26 |
-
Authorization: `Bearer ${token}`,
|
| 27 |
-
"X-GitHub-Api-Version": "2022-11-28",
|
| 28 |
-
"Content-Type": "application/json",
|
| 29 |
-
...(init?.headers || {}),
|
| 30 |
-
},
|
| 31 |
-
});
|
| 32 |
-
if (!res.ok) {
|
| 33 |
-
const text = await res.text();
|
| 34 |
-
throw new Error(`GitHub ${res.status}: ${text}`);
|
| 35 |
-
}
|
| 36 |
-
return res.json();
|
| 37 |
-
}
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
export type CreateSubmissionInput = {
|
| 42 |
trial_id: string;
|
|
@@ -45,95 +43,215 @@ export type CreateSubmissionInput = {
|
|
| 45 |
comparison: unknown;
|
| 46 |
};
|
| 47 |
|
| 48 |
-
export type CreateSubmissionResult = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
export async function createSubmission(
|
| 51 |
input: CreateSubmissionInput,
|
| 52 |
-
): Promise<CreateSubmissionResult
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
const baseName = `${safe(input.trial_id)}__${safe(input.username)}__${stamp}`;
|
| 57 |
-
const filePath = `submissions/${baseName}.json`;
|
| 58 |
-
const fileContent = JSON.stringify(input, null, 2);
|
| 59 |
-
const commitMsg = `Add submission: ${input.trial_id} — ${input.username}`;
|
| 60 |
-
const fileRes = await gh<{ content: { html_url: string } }>(
|
| 61 |
-
`/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath).replace(/%2F/g, "/")}`,
|
| 62 |
-
{
|
| 63 |
-
method: "PUT",
|
| 64 |
-
body: JSON.stringify({
|
| 65 |
-
message: commitMsg,
|
| 66 |
-
content: Buffer.from(fileContent, "utf-8").toString("base64"),
|
| 67 |
-
}),
|
| 68 |
-
},
|
| 69 |
-
);
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
);
|
| 81 |
return {
|
| 82 |
-
submissionId
|
| 83 |
-
url:
|
| 84 |
-
fileUrl: fileRes.content.html_url,
|
| 85 |
};
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
| 90 |
-
const submissionId = `${safe(input.trial_id)}__${safe(input.username)}__${stamp}`;
|
| 91 |
-
const dir = path.join(process.cwd(), "data", "submissions", submissionId);
|
| 92 |
await fs.mkdir(dir, { recursive: true });
|
| 93 |
-
await fs.writeFile(
|
| 94 |
-
path.join(dir, "prompts.json"),
|
| 95 |
-
JSON.stringify({ ...input, submissionId }, null, 2),
|
| 96 |
-
"utf-8",
|
| 97 |
-
);
|
| 98 |
return { submissionId };
|
| 99 |
}
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
.
|
| 105 |
-
(
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Storage backend: Hugging Face Dataset repo (production) or local filesystem (dev).
|
| 2 |
//
|
| 3 |
+
// In production set these env vars in Vercel:
|
| 4 |
+
// HF_TOKEN — HF user access token with "Write" permission on the dataset repo
|
| 5 |
+
// HF_DATASET_REPO — e.g. "ttt-77/tdb-intake-submissions"
|
| 6 |
+
// HF_DATASET_BRANCH — optional, defaults to "main"
|
| 7 |
//
|
| 8 |
+
// If HF_TOKEN or HF_DATASET_REPO is missing, the code falls back to ./data/submissions/*.json
|
| 9 |
+
// on disk so local development still works without HF credentials.
|
| 10 |
|
| 11 |
import { promises as fs } from "fs";
|
| 12 |
import path from "path";
|
| 13 |
|
| 14 |
+
const HF_BASE = "https://huggingface.co";
|
| 15 |
+
const HF_TOKEN = process.env.HF_TOKEN;
|
| 16 |
+
const HF_DATASET = process.env.HF_DATASET_REPO;
|
| 17 |
+
const HF_BRANCH = process.env.HF_DATASET_BRANCH || "main";
|
| 18 |
+
|
| 19 |
+
export const hfConfigured = !!(HF_TOKEN && HF_DATASET);
|
| 20 |
|
| 21 |
const safe = (s: string) => (s || "").trim().replace(/[^a-zA-Z0-9-_]/g, "_");
|
| 22 |
|
| 23 |
+
// ---- Types ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
export type SubmissionStatus = "pending" | "reviewed" | "needs_fix";
|
| 26 |
+
|
| 27 |
+
export type SubmissionRecord = {
|
| 28 |
+
submissionId: string; // == path inside the dataset repo, e.g. "submissions/foo.json"
|
| 29 |
+
submittedAt: string;
|
| 30 |
+
trial_id: string;
|
| 31 |
+
username: string;
|
| 32 |
+
status: SubmissionStatus;
|
| 33 |
+
reviewedAt?: string;
|
| 34 |
+
reviewer?: string;
|
| 35 |
+
reviewerNote?: string;
|
| 36 |
+
comparison: unknown;
|
| 37 |
+
};
|
| 38 |
|
| 39 |
export type CreateSubmissionInput = {
|
| 40 |
trial_id: string;
|
|
|
|
| 43 |
comparison: unknown;
|
| 44 |
};
|
| 45 |
|
| 46 |
+
export type CreateSubmissionResult = {
|
| 47 |
+
submissionId: string;
|
| 48 |
+
url?: string;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
export type SubmissionSummary = {
|
| 52 |
+
submissionId: string;
|
| 53 |
+
trial_id: string;
|
| 54 |
+
username: string;
|
| 55 |
+
submittedAt: string;
|
| 56 |
+
status: SubmissionStatus;
|
| 57 |
+
reviewedAt?: string;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// ---- HF helpers -----------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
async function hfCommit(
|
| 63 |
+
filePath: string,
|
| 64 |
+
contentBase64: string,
|
| 65 |
+
summary: string,
|
| 66 |
+
): Promise<void> {
|
| 67 |
+
// HF commit API takes NDJSON: a header line, then one file line per upload.
|
| 68 |
+
const url = `${HF_BASE}/api/datasets/${HF_DATASET}/commit/${HF_BRANCH}`;
|
| 69 |
+
const body =
|
| 70 |
+
JSON.stringify({ key: "header", value: { summary, description: "" } }) +
|
| 71 |
+
"\n" +
|
| 72 |
+
JSON.stringify({
|
| 73 |
+
key: "file",
|
| 74 |
+
value: { path: filePath, encoding: "base64", content: contentBase64 },
|
| 75 |
+
}) +
|
| 76 |
+
"\n";
|
| 77 |
+
const res = await fetch(url, {
|
| 78 |
+
method: "POST",
|
| 79 |
+
headers: {
|
| 80 |
+
Authorization: `Bearer ${HF_TOKEN}`,
|
| 81 |
+
"Content-Type": "application/x-ndjson",
|
| 82 |
+
},
|
| 83 |
+
body,
|
| 84 |
+
});
|
| 85 |
+
if (!res.ok) {
|
| 86 |
+
throw new Error(`HF commit failed (${res.status}): ${await res.text()}`);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
async function hfReadFile(filePath: string): Promise<string | null> {
|
| 91 |
+
const url = `${HF_BASE}/datasets/${HF_DATASET}/resolve/${HF_BRANCH}/${filePath}`;
|
| 92 |
+
const res = await fetch(url, {
|
| 93 |
+
headers: { Authorization: `Bearer ${HF_TOKEN}` },
|
| 94 |
+
cache: "no-store",
|
| 95 |
+
});
|
| 96 |
+
if (!res.ok) return null;
|
| 97 |
+
return res.text();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async function hfListSubmissions(): Promise<string[]> {
|
| 101 |
+
const url = `${HF_BASE}/api/datasets/${HF_DATASET}/tree/${HF_BRANCH}/submissions`;
|
| 102 |
+
const res = await fetch(url, {
|
| 103 |
+
headers: { Authorization: `Bearer ${HF_TOKEN}` },
|
| 104 |
+
cache: "no-store",
|
| 105 |
+
});
|
| 106 |
+
if (res.status === 404) return [];
|
| 107 |
+
if (!res.ok) {
|
| 108 |
+
throw new Error(`HF tree failed (${res.status}): ${await res.text()}`);
|
| 109 |
+
}
|
| 110 |
+
const items = (await res.json()) as Array<{ path: string; type: string }>;
|
| 111 |
+
return items
|
| 112 |
+
.filter((i) => i.type === "file" && i.path.endsWith(".json"))
|
| 113 |
+
.map((i) => i.path);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ---- Public API -----------------------------------------------------------
|
| 117 |
|
| 118 |
export async function createSubmission(
|
| 119 |
input: CreateSubmissionInput,
|
| 120 |
+
): Promise<CreateSubmissionResult> {
|
| 121 |
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
| 122 |
+
const fileName = `${safe(input.trial_id)}__${safe(input.username)}__${stamp}.json`;
|
| 123 |
+
const submissionId = `submissions/${fileName}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
+
const record: SubmissionRecord = {
|
| 126 |
+
submissionId,
|
| 127 |
+
submittedAt: input.submittedAt,
|
| 128 |
+
trial_id: input.trial_id,
|
| 129 |
+
username: input.username,
|
| 130 |
+
status: "pending",
|
| 131 |
+
comparison: input.comparison,
|
| 132 |
+
};
|
| 133 |
+
const content = JSON.stringify(record, null, 2);
|
| 134 |
+
|
| 135 |
+
if (hfConfigured) {
|
| 136 |
+
await hfCommit(
|
| 137 |
+
submissionId,
|
| 138 |
+
Buffer.from(content, "utf-8").toString("base64"),
|
| 139 |
+
`Add submission: ${input.trial_id} — ${input.username}`,
|
| 140 |
);
|
| 141 |
return {
|
| 142 |
+
submissionId,
|
| 143 |
+
url: `${HF_BASE}/datasets/${HF_DATASET}/blob/${HF_BRANCH}/${submissionId}`,
|
|
|
|
| 144 |
};
|
| 145 |
}
|
| 146 |
|
| 147 |
+
const dir = path.join(process.cwd(), "data", "submissions");
|
|
|
|
|
|
|
|
|
|
| 148 |
await fs.mkdir(dir, { recursive: true });
|
| 149 |
+
await fs.writeFile(path.join(dir, fileName), content, "utf-8");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return { submissionId };
|
| 151 |
}
|
| 152 |
|
| 153 |
+
export async function listSubmissions(): Promise<SubmissionSummary[]> {
|
| 154 |
+
if (hfConfigured) {
|
| 155 |
+
const paths = await hfListSubmissions();
|
| 156 |
+
const records = await Promise.all(
|
| 157 |
+
paths.map(async (p) => {
|
| 158 |
+
const text = await hfReadFile(p);
|
| 159 |
+
if (!text) return null;
|
| 160 |
+
try {
|
| 161 |
+
const r = JSON.parse(text) as SubmissionRecord;
|
| 162 |
+
return summarize(r);
|
| 163 |
+
} catch {
|
| 164 |
+
return null;
|
| 165 |
+
}
|
| 166 |
+
}),
|
| 167 |
+
);
|
| 168 |
+
return records.filter((x): x is SubmissionSummary => x !== null);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const dir = path.join(process.cwd(), "data", "submissions");
|
| 172 |
+
let files: string[] = [];
|
| 173 |
+
try {
|
| 174 |
+
files = await fs.readdir(dir);
|
| 175 |
+
} catch {
|
| 176 |
+
return [];
|
| 177 |
+
}
|
| 178 |
+
const records = await Promise.all(
|
| 179 |
+
files
|
| 180 |
+
.filter((f) => f.endsWith(".json"))
|
| 181 |
+
.map(async (f) => {
|
| 182 |
+
try {
|
| 183 |
+
const raw = await fs.readFile(path.join(dir, f), "utf-8");
|
| 184 |
+
return summarize(JSON.parse(raw));
|
| 185 |
+
} catch {
|
| 186 |
+
return null;
|
| 187 |
+
}
|
| 188 |
+
}),
|
| 189 |
+
);
|
| 190 |
+
return records.filter((x): x is SubmissionSummary => x !== null);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
export async function getSubmission(submissionId: string): Promise<SubmissionRecord | null> {
|
| 194 |
+
if (!submissionId.startsWith("submissions/")) return null;
|
| 195 |
+
if (hfConfigured) {
|
| 196 |
+
const txt = await hfReadFile(submissionId);
|
| 197 |
+
if (!txt) return null;
|
| 198 |
+
try {
|
| 199 |
+
return JSON.parse(txt);
|
| 200 |
+
} catch {
|
| 201 |
+
return null;
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
const fullPath = path.join(process.cwd(), "data", submissionId);
|
| 205 |
+
try {
|
| 206 |
+
return JSON.parse(await fs.readFile(fullPath, "utf-8"));
|
| 207 |
+
} catch {
|
| 208 |
+
return null;
|
| 209 |
+
}
|
| 210 |
}
|
| 211 |
|
| 212 |
+
export async function updateSubmission(
|
| 213 |
+
submissionId: string,
|
| 214 |
+
patch: Partial<Pick<SubmissionRecord, "status" | "reviewer" | "reviewerNote">>,
|
| 215 |
+
): Promise<SubmissionRecord | null> {
|
| 216 |
+
const existing = await getSubmission(submissionId);
|
| 217 |
+
if (!existing) return null;
|
| 218 |
+
const updated: SubmissionRecord = {
|
| 219 |
+
...existing,
|
| 220 |
+
...patch,
|
| 221 |
+
reviewedAt: patch.status ? new Date().toISOString() : existing.reviewedAt,
|
| 222 |
+
};
|
| 223 |
+
const content = JSON.stringify(updated, null, 2);
|
| 224 |
+
|
| 225 |
+
if (hfConfigured) {
|
| 226 |
+
await hfCommit(
|
| 227 |
+
submissionId,
|
| 228 |
+
Buffer.from(content, "utf-8").toString("base64"),
|
| 229 |
+
`Update: ${submissionId.split("/").pop()}`,
|
| 230 |
+
);
|
| 231 |
+
return updated;
|
| 232 |
+
}
|
| 233 |
+
const fullPath = path.join(process.cwd(), "data", submissionId);
|
| 234 |
+
await fs.writeFile(fullPath, content, "utf-8");
|
| 235 |
+
return updated;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function summarize(r: SubmissionRecord): SubmissionSummary {
|
| 239 |
+
return {
|
| 240 |
+
submissionId: r.submissionId,
|
| 241 |
+
trial_id: r.trial_id,
|
| 242 |
+
username: r.username,
|
| 243 |
+
submittedAt: r.submittedAt,
|
| 244 |
+
status: r.status ?? "pending",
|
| 245 |
+
reviewedAt: r.reviewedAt,
|
| 246 |
+
};
|
| 247 |
}
|
| 248 |
|
| 249 |
+
// ---- Admin gate (shared password) ----------------------------------------
|
| 250 |
+
|
| 251 |
+
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
| 252 |
+
|
| 253 |
+
export function isAdminAuthorized(req: Request): boolean {
|
| 254 |
+
if (!ADMIN_PASSWORD) return true; // no password set = open (dev mode)
|
| 255 |
+
const header = req.headers.get("x-admin-password") || "";
|
| 256 |
+
return header === ADMIN_PASSWORD;
|
| 257 |
+
}
|