Spaces:
Running
Running
Deploy Rachana Data Studio
Browse files- data_studio/api.py +6 -2
- data_studio/review.py +10 -2
- frontend/app/globals.css +5 -1
- frontend/app/page.tsx +15 -6
data_studio/api.py
CHANGED
|
@@ -167,8 +167,12 @@ def overview(user: dict[str, Any] = Depends(current_user)) -> dict[str, Any]:
|
|
| 167 |
|
| 168 |
|
| 169 |
@app.post("/api/review/claim/{queue_name}")
|
| 170 |
-
def claim(
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
@app.post("/api/review/{sample_id}/release")
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
@app.post("/api/review/claim/{queue_name}")
|
| 170 |
+
def claim(
|
| 171 |
+
queue_name: str,
|
| 172 |
+
dataset_key: str | None = None,
|
| 173 |
+
user: dict[str, Any] = Depends(require_permission("review")),
|
| 174 |
+
) -> dict[str, Any]:
|
| 175 |
+
return {"sample": claim_next_sample_with_enrichment(db, settings, queue_name, user["username"], dataset_key)}
|
| 176 |
|
| 177 |
|
| 178 |
@app.post("/api/review/{sample_id}/release")
|
data_studio/review.py
CHANGED
|
@@ -56,7 +56,12 @@ def queue_overview(db: Database, settings: StudioSettings) -> dict[str, Any]:
|
|
| 56 |
}
|
| 57 |
|
| 58 |
|
| 59 |
-
def claim_next_sample(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
now = utc_now()
|
| 61 |
expires_at = now + timedelta(minutes=LEASE_MINUTES)
|
| 62 |
lease_filter = {
|
|
@@ -68,6 +73,8 @@ def claim_next_sample(db: Database, queue_name: str, reviewer: str) -> dict[str,
|
|
| 68 |
{"lease.reviewer": reviewer},
|
| 69 |
],
|
| 70 |
}
|
|
|
|
|
|
|
| 71 |
candidate_ids = [
|
| 72 |
row["_id"]
|
| 73 |
for row in db["samples"].aggregate(
|
|
@@ -106,8 +113,9 @@ def claim_next_sample_with_enrichment(
|
|
| 106 |
settings: StudioSettings,
|
| 107 |
queue_name: str,
|
| 108 |
reviewer: str,
|
|
|
|
| 109 |
) -> dict[str, Any] | None:
|
| 110 |
-
return claim_next_sample(db, queue_name, reviewer)
|
| 111 |
|
| 112 |
|
| 113 |
def next_sample(db: Database, queue_name: str) -> dict[str, Any] | None:
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
|
| 59 |
+
def claim_next_sample(
|
| 60 |
+
db: Database,
|
| 61 |
+
queue_name: str,
|
| 62 |
+
reviewer: str,
|
| 63 |
+
dataset_key: str | None = None,
|
| 64 |
+
) -> dict[str, Any] | None:
|
| 65 |
now = utc_now()
|
| 66 |
expires_at = now + timedelta(minutes=LEASE_MINUTES)
|
| 67 |
lease_filter = {
|
|
|
|
| 73 |
{"lease.reviewer": reviewer},
|
| 74 |
],
|
| 75 |
}
|
| 76 |
+
if dataset_key:
|
| 77 |
+
lease_filter["source.dataset_key"] = dataset_key
|
| 78 |
candidate_ids = [
|
| 79 |
row["_id"]
|
| 80 |
for row in db["samples"].aggregate(
|
|
|
|
| 113 |
settings: StudioSettings,
|
| 114 |
queue_name: str,
|
| 115 |
reviewer: str,
|
| 116 |
+
dataset_key: str | None = None,
|
| 117 |
) -> dict[str, Any] | None:
|
| 118 |
+
return claim_next_sample(db, queue_name, reviewer, dataset_key=dataset_key)
|
| 119 |
|
| 120 |
|
| 121 |
def next_sample(db: Database, queue_name: str) -> dict[str, Any] | None:
|
frontend/app/globals.css
CHANGED
|
@@ -13,7 +13,11 @@
|
|
| 13 |
.reviewStack{display:grid;gap:18px}
|
| 14 |
.decisionCardBottom{width:100%}
|
| 15 |
.connectedCard p{color:var(--muted);margin:0 0 12px}
|
|
|
|
|
|
|
|
|
|
| 16 |
.connectedList{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
|
| 17 |
-
.connectedChip{border:1px solid var(--line);border-radius:12px;padding:12px;background:#f7f8f4;display:grid;gap:4px}
|
|
|
|
| 18 |
.connectedChip small{color:var(--muted)}
|
| 19 |
@media(max-width:760px){.shellCollapsed{grid-template-columns:1fr}.sidebarToggle{width:100%}}
|
|
|
|
| 13 |
.reviewStack{display:grid;gap:18px}
|
| 14 |
.decisionCardBottom{width:100%}
|
| 15 |
.connectedCard p{color:var(--muted);margin:0 0 12px}
|
| 16 |
+
.connectedHeader{display:flex;justify-content:space-between;gap:14px;align-items:end;margin-bottom:14px}
|
| 17 |
+
.connectedHeader label{display:grid;gap:6px;font-size:12px;font-weight:700;min-width:240px}
|
| 18 |
+
.connectedHeader select{border:1px solid var(--line);border-radius:10px;padding:10px 12px;background:#fff;color:var(--ink)}
|
| 19 |
.connectedList{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
|
| 20 |
+
.connectedChip{border:1px solid var(--line);border-radius:12px;padding:12px;background:#f7f8f4;display:grid;gap:4px;text-align:left}
|
| 21 |
+
.connectedChip.active{border-color:var(--green);background:var(--green-soft)}
|
| 22 |
.connectedChip small{color:var(--muted)}
|
| 23 |
@media(max-width:760px){.shellCollapsed{grid-template-columns:1fr}.sidebarToggle{width:100%}}
|
frontend/app/page.tsx
CHANGED
|
@@ -130,7 +130,7 @@ export default function Home() {
|
|
| 130 |
const [message, setMessage] = useState("");
|
| 131 |
const [authChecked, setAuthChecked] = useState(false);
|
| 132 |
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
| 133 |
-
const canAccessAdmin = user ? user.permissions.some((p) => ["import", "export", "manage_sources", "manage_users"].includes(p)) : false;
|
| 134 |
|
| 135 |
const refreshOverview = useCallback(async (authToken: string) => {
|
| 136 |
const data = await api<{ queues: QueueCounts; sources: Source[]; user: User; token_progress: TokenProgress; collaboration: Collaboration }>("/api/overview", authToken);
|
|
@@ -160,11 +160,12 @@ export default function Home() {
|
|
| 160 |
}).finally(() => setAuthChecked(true));
|
| 161 |
}, [refreshOverview]);
|
| 162 |
|
| 163 |
-
const claim = async (selectedQueue = queue) => {
|
| 164 |
setBusy(true);
|
| 165 |
setMessage("");
|
| 166 |
try {
|
| 167 |
-
const
|
|
|
|
| 168 |
setSample(data.sample);
|
| 169 |
if (!data.sample) setMessage("This queue has no available pending samples.");
|
| 170 |
} catch (error) {
|
|
@@ -253,7 +254,7 @@ export default function Home() {
|
|
| 253 |
</aside>
|
| 254 |
<main>
|
| 255 |
{message && <div className="notice" role="status">{message}<button onClick={() => setMessage("")}>Dismiss</button></div>}
|
| 256 |
-
{section === "review" && <ReviewWorkspace queue={queue} queues={queues} sources={sources} sample={sample} busy={busy} collaboration={collaboration} onQueue={changeQueue} onClaim={() => claim()} onSubmit={submitReview} />}
|
| 257 |
{section === "overview" && <Overview queues={queues} sources={sources} tokenProgress={tokenProgress} collaboration={collaboration} />}
|
| 258 |
{section === "admin" && <Admin token={token} sources={sources} permissions={user.permissions} onDone={() => refreshOverview(token)} />}
|
| 259 |
</main>
|
|
@@ -286,8 +287,16 @@ type EditorState = { cleaned: Record<string, unknown>; tags: string; notes: stri
|
|
| 286 |
|
| 287 |
type ReviewLine = { id: string; left: string; right: string };
|
| 288 |
|
| 289 |
-
function ReviewWorkspace({ queue, queues, sources, sample, busy, collaboration, onQueue, onClaim, onSubmit }: { queue: string; queues: QueueCounts; sources: Source[]; sample: Sample | null; busy: boolean; collaboration: Collaboration | null; onQueue: (q: string) => void; onClaim: () => void; onSubmit: (action: string, editor: EditorState) => void }) {
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
}
|
| 292 |
|
| 293 |
function ReviewEditor({ sample, busy, onSubmit }: { sample: Sample; busy: boolean; onSubmit: (action: string, editor: EditorState) => void }) {
|
|
|
|
| 130 |
const [message, setMessage] = useState("");
|
| 131 |
const [authChecked, setAuthChecked] = useState(false);
|
| 132 |
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
| 133 |
+
const canAccessAdmin = user ? user.role === "admin" || user.permissions.some((p) => ["import", "export", "manage_sources", "manage_users"].includes(p)) : false;
|
| 134 |
|
| 135 |
const refreshOverview = useCallback(async (authToken: string) => {
|
| 136 |
const data = await api<{ queues: QueueCounts; sources: Source[]; user: User; token_progress: TokenProgress; collaboration: Collaboration }>("/api/overview", authToken);
|
|
|
|
| 160 |
}).finally(() => setAuthChecked(true));
|
| 161 |
}, [refreshOverview]);
|
| 162 |
|
| 163 |
+
const claim = async (selectedQueue = queue, selectedDatasetKey?: string) => {
|
| 164 |
setBusy(true);
|
| 165 |
setMessage("");
|
| 166 |
try {
|
| 167 |
+
const datasetQuery = selectedDatasetKey ? `?dataset_key=${encodeURIComponent(selectedDatasetKey)}` : "";
|
| 168 |
+
const data = await api<{ sample: Sample | null }>(`/api/review/claim/${selectedQueue}${datasetQuery}`, token, { method: "POST" });
|
| 169 |
setSample(data.sample);
|
| 170 |
if (!data.sample) setMessage("This queue has no available pending samples.");
|
| 171 |
} catch (error) {
|
|
|
|
| 254 |
</aside>
|
| 255 |
<main>
|
| 256 |
{message && <div className="notice" role="status">{message}<button onClick={() => setMessage("")}>Dismiss</button></div>}
|
| 257 |
+
{section === "review" && <ReviewWorkspace queue={queue} queues={queues} sources={sources} sample={sample} busy={busy} collaboration={collaboration} onQueue={changeQueue} onClaim={(datasetKey) => claim(queue, datasetKey)} onSubmit={submitReview} />}
|
| 258 |
{section === "overview" && <Overview queues={queues} sources={sources} tokenProgress={tokenProgress} collaboration={collaboration} />}
|
| 259 |
{section === "admin" && <Admin token={token} sources={sources} permissions={user.permissions} onDone={() => refreshOverview(token)} />}
|
| 260 |
</main>
|
|
|
|
| 287 |
|
| 288 |
type ReviewLine = { id: string; left: string; right: string };
|
| 289 |
|
| 290 |
+
function ReviewWorkspace({ queue, queues, sources, sample, busy, collaboration, onQueue, onClaim, onSubmit }: { queue: string; queues: QueueCounts; sources: Source[]; sample: Sample | null; busy: boolean; collaboration: Collaboration | null; onQueue: (q: string) => void; onClaim: (datasetKey?: string) => void; onSubmit: (action: string, editor: EditorState) => void }) {
|
| 291 |
+
const [datasetKey, setDatasetKey] = useState(sources[0]?.dataset_key || "");
|
| 292 |
+
useEffect(() => {
|
| 293 |
+
if (sources.length && !sources.some((source) => source.dataset_key === datasetKey)) {
|
| 294 |
+
setDatasetKey(sources[0].dataset_key);
|
| 295 |
+
}
|
| 296 |
+
}, [datasetKey, sources]);
|
| 297 |
+
const selectedSource = sources.find((source) => source.dataset_key === datasetKey);
|
| 298 |
+
const selectedQueue = selectedSource?.queue_name || queue;
|
| 299 |
+
return <><header className="pageHeader"><div><span className="eyebrow">Review workspace</span><h1>Make one clear decision at a time.</h1><p>Each reviewer gets a random available sample. Token progress stays global across the whole team.</p></div><div className="headerStats">{collaboration && <div className="metricInline"><span>Total cleaned</span><strong>{collaboration.total_cleaned_samples.toLocaleString()}</strong></div>}{sample?.lease && <div className="lease">Reserved until {new Date(sample.lease.expires_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>}</div></header><section className="tableCard connectedCard"><div className="connectedHeader"><div><h2>Connected datasets</h2><p>Pick a dataset, then claim from its queue. This keeps review focused without hiding other sources.</p></div><label>Active dataset<select value={datasetKey} onChange={(e) => { const next = e.target.value; setDatasetKey(next); const nextSource = sources.find((source) => source.dataset_key === next); if (nextSource) onQueue(nextSource.queue_name); }}>{sources.length ? null : <option value="">No connected datasets</option>}<option value="">Select a dataset</option>{sources.map((source) => <option key={source.dataset_key} value={source.dataset_key}>{source.name}</option>)}</select></label></div><div className="connectedList">{sources.map((source) => <button key={source.dataset_key} type="button" className={`connectedChip${source.dataset_key === datasetKey ? " active" : ""}`} onClick={() => { setDatasetKey(source.dataset_key); onQueue(source.queue_name); }}><strong>{source.name}</strong><small>{source.dataset_key}</small></button>)}</div></section><div className="queueTabs">{QUEUES.map((item) => <button key={item.id} className={queue === item.id ? "active" : ""} onClick={() => onQueue(item.id)}><strong>{item.label}</strong><span>{queues[item.id]?.pending || 0} pending</span></button>)}</div>{sample ? <ReviewEditor key={sample.sample_id} sample={sample} busy={busy} onSubmit={onSubmit} /> : <div className="emptyState"><div>✓</div><h2>Ready for the next item?</h2><p>{selectedSource ? `${selectedSource.name} is mapped to ${selectedQueue}.` : QUEUES.find((q) => q.id === queue)?.description}</p><button className="primary" onClick={() => onClaim(datasetKey || undefined)} disabled={busy}>{busy ? "Finding an item..." : "Claim random sample"}</button></div>}</>;
|
| 300 |
}
|
| 301 |
|
| 302 |
function ReviewEditor({ sample, busy, onSubmit }: { sample: Sample; busy: boolean; onSubmit: (action: string, editor: EditorState) => void }) {
|