tttjjj commited on
Commit
64e3b80
·
1 Parent(s): 40d7696

Switch storage to Hugging Face Dataset; add /admin review console

Browse files
README.md CHANGED
@@ -1,14 +1,11 @@
1
  # Clinical Trial AI Reproduction Benchmark — Intake
2
 
3
- A Next.js + Tailwind single-page intake form. Statisticians enter:
4
 
5
- - `trial_id`, `username`
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 GitHub env vars set, submissions fall back to `./data/submissions/<trial_id>__<username>__<timestamp>/prompts.json` on disk.
22
-
23
- ## Deploy on Vercel + store submissions on GitHub
24
-
25
- ### 1. Create a private submissions repo
26
 
27
- Create an empty repo, e.g. `ttt-77/tdb-intake-submissions`. Every submission will:
28
 
29
- - open a new **issue** there (Markdown summary + raw JSON in a fenced block).
30
- - commit the raw JSON file to `submissions/<trial_id>__<username>__<timestamp>.json` in the same repo.
31
 
32
- ### 2. Make a fine-grained PAT
 
 
 
 
 
33
 
34
- GitHub Settings Developer settings → **Fine-grained tokens** → Generate new token.
35
 
36
- - **Repository access:** only the submissions repo.
37
- - **Permissions → Repository:**
38
- - **Contents:** Read and write (for the file commit).
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 add . && git commit -m "Initial intake form"
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
- | `GITHUB_TOKEN` | the fine-grained PAT from step 2 |
59
- | `GITHUB_OWNER` | repo owner, e.g. `ttt-77` |
60
- | `GITHUB_REPO` | repo name, e.g. `tdb-intake-submissions` |
 
61
 
62
  Redeploy after saving.
63
 
64
  ### 5. Test
65
 
66
- Fill the form on the deployed URL **Submit**. In your submissions repo you should see:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- - A new file at `submissions/<trial_id>__<username>__<timestamp>.json`.
69
- - A new issue titled `[Intake] <trial_id> — <username>`, labeled `intake-submission`, linking to that file.
70
 
71
- The UI shows links to both right after submit.
 
 
 
 
 
 
 
72
 
73
  ## Privacy notes
74
 
75
- - The submissions repo should be **private**.
76
- - `GITHUB_TOKEN` lives only in Vercel env vars — never commit it.
77
- - Rotate the PAT periodically; update the env var and redeploy.
 
78
 
79
  ## Project structure
80
 
81
  ```text
82
  app/
83
- layout.tsx, page.tsx, globals.css
84
- api/submit/route.ts POST commits JSON file + opens issue (or writes locally)
 
 
 
85
  components/
86
  StepCompare.tsx
87
  lib/
88
  types.ts, storage.ts
89
- data/submissions/ — created at runtime in dev (local fallback only)
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
- <h1 className="text-lg font-semibold">Clinical Trial AI Reproduction Benchmark</h1>
17
- <p className="text-xs text-slate-500">Statistician intake & evaluation form</p>
 
 
 
 
 
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: GitHub Issues (production) or local filesystem (dev fallback).
2
  //
3
- // In production set the following env vars (Vercel → Project → Settings → Environment Variables):
4
- // GITHUB_TOKEN fine-grained PAT with Issues: read/write on the submissions repo
5
- // GITHUB_OWNER repo owner (e.g. ttt-77)
6
- // GITHUB_REPO repo name (e.g. tdb-intake-submissions)
7
  //
8
- // If any of those are missing, writes go to ./data/submissions/<id>/*.json on disk.
 
9
 
10
  import { promises as fs } from "fs";
11
  import path from "path";
12
 
13
- const token = process.env.GITHUB_TOKEN;
14
- const owner = process.env.GITHUB_OWNER;
15
- const repo = process.env.GITHUB_REPO;
16
- export const githubConfigured = !!(token && owner && repo);
 
 
17
 
18
  const safe = (s: string) => (s || "").trim().replace(/[^a-zA-Z0-9-_]/g, "_");
19
 
20
- async function gh<T = any>(pathname: string, init?: RequestInit): Promise<T> {
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
- // ---- Submissions ----------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
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 = { submissionId: string; url?: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  export async function createSubmission(
51
  input: CreateSubmissionInput,
52
- ): Promise<CreateSubmissionResult & { fileUrl?: string }> {
53
- if (githubConfigured) {
54
- // 1. Commit the raw submission JSON to the repo.
55
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
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
- // 2. Open an issue referencing the file.
72
- const title = `[Intake] ${input.trial_id} — ${input.username}`;
73
- const body = renderIssueBody(input, fileRes.content.html_url);
74
- const issue = await gh<{ number: number; html_url: string }>(
75
- `/repos/${owner}/${repo}/issues`,
76
- {
77
- method: "POST",
78
- body: JSON.stringify({ title, body, labels: ["intake-submission"] }),
79
- },
 
 
 
 
 
 
80
  );
81
  return {
82
- submissionId: String(issue.number),
83
- url: issue.html_url,
84
- fileUrl: fileRes.content.html_url,
85
  };
86
  }
87
 
88
- // local fs fallback
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
- function renderIssueBody(input: CreateSubmissionInput, fileUrl?: string): string {
102
- const c = input.comparison as { prompts?: any[] };
103
- const rows = (c?.prompts ?? [])
104
- .map(
105
- (p: any) =>
106
- `| \`${p.id}\` | ${escapeCell(p.design_element)} | ${escapeCell(p.question)} | \`${
107
- p.question_type
108
- }\` |`,
109
- )
110
- .join("\n");
111
- const table =
112
- rows.length > 0
113
- ? `| id | design_element | question | question_type |\n|---|---|---|---|\n${rows}`
114
- : "_No questions submitted._";
115
-
116
- return [
117
- `**Submitted:** ${input.submittedAt}`,
118
- `**trial_id:** \`${input.trial_id}\``,
119
- `**username:** \`${input.username}\``,
120
- fileUrl ? `**Raw submission file:** ${fileUrl}` : "",
121
- "",
122
- "### Questions",
123
- "",
124
- table,
125
- "",
126
- "### Raw submission",
127
- "",
128
- "```json",
129
- JSON.stringify(input, null, 2),
130
- "```",
131
- ]
132
- .filter(Boolean)
133
- .join("\n");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  }
135
 
136
- function escapeCell(s: string | undefined): string {
137
- return (s || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }