Spaces:
Sleeping
Sleeping
| /** | |
| * Primary "Run the ranker" control panel. | |
| * | |
| * Three orthogonal ways to feed candidates to the API: | |
| * 1. Upload your own `.json/.jsonl/.jsonl.gz` (drag-and-drop). | |
| * 2. Click "Feed sample candidates" → uses the default fixture. | |
| * 3. Use the `prefilled` prop from the parent (e.g. resume parser). | |
| * | |
| * Schema-validation errors are NOT caught here - they bubble up to | |
| * App.tsx so it can render the dedicated <SchemaDiff/> panel. | |
| */ | |
| import React, { useEffect, useState } from "react"; | |
| import { useDropzone } from "react-dropzone"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { postRank, SchemaValidationError } from "../api"; | |
| import type { RankResponse } from "../types"; | |
| interface Props { | |
| onResult: (r: RankResponse) => void; | |
| onError: (msg: string) => void; | |
| onSchemaError: (err: SchemaValidationError) => void; | |
| prefilled?: File | null; | |
| } | |
| export const RunPanel: React.FC<Props> = ({ | |
| onResult, | |
| onError, | |
| onSchemaError, | |
| prefilled, | |
| }) => { | |
| const [candidates, setCandidates] = useState<File | null>(null); | |
| const [jd, setJd] = useState<File | null>(null); | |
| const [topK, setTopK] = useState<number>(10); | |
| const [busy, setBusy] = useState(false); | |
| const [mode, setMode] = useState<"upload" | "sample">("upload"); | |
| useEffect(() => { | |
| if (prefilled) { | |
| setCandidates(prefilled); | |
| setMode("upload"); | |
| } | |
| }, [prefilled]); | |
| async function runUpload() { | |
| if (!candidates) return; | |
| await runRequest({ candidates, jd, topK }); | |
| } | |
| async function runSample() { | |
| setMode("sample"); | |
| await runRequest({ useSample: true, jd, topK }); | |
| } | |
| async function runRequest(opts: { | |
| candidates?: File | null; | |
| jd?: File | null; | |
| topK?: number; | |
| useSample?: boolean; | |
| }) { | |
| setBusy(true); | |
| try { | |
| const res = await postRank(opts); | |
| onResult(res); | |
| } catch (e) { | |
| if (e instanceof SchemaValidationError) { | |
| onSchemaError(e); | |
| } else { | |
| onError(e instanceof Error ? e.message : String(e)); | |
| } | |
| } finally { | |
| setBusy(false); | |
| } | |
| } | |
| // NOTE: many browsers report `.jsonl` as `application/octet-stream` and | |
| // some report `.gz` as `application/x-gzip`. Listing the extensions under | |
| // a wildcard MIME key tells react-dropzone to match by extension first, | |
| // which is what we want. | |
| const candidateDrop = useDropzone({ | |
| onDrop: (files) => files[0] && setCandidates(files[0]), | |
| multiple: false, | |
| accept: { | |
| "application/json": [".json"], | |
| "application/x-ndjson": [".jsonl"], | |
| "application/octet-stream": [".jsonl", ".gz"], | |
| "application/gzip": [".gz"], | |
| "application/x-gzip": [".gz"], | |
| }, | |
| // 600 MB - the official candidates.jsonl is ~480 MB. | |
| maxSize: 600 * 1024 * 1024, | |
| }); | |
| // JD upload — accept text, markdown, .docx and .pdf. The official Redrob | |
| // bundle ships `job_description.docx`, so this must Just Work. | |
| const jdDrop = useDropzone({ | |
| onDrop: (files) => files[0] && setJd(files[0]), | |
| multiple: false, | |
| accept: { | |
| "text/plain": [".txt", ".md"], | |
| "text/markdown": [".md"], | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], | |
| "application/pdf": [".pdf"], | |
| "application/octet-stream": [".docx", ".pdf", ".txt", ".md"], | |
| }, | |
| maxSize: 10 * 1024 * 1024, | |
| }); | |
| return ( | |
| <section className="card-glow p-6 sm:p-8 transition-transform duration-500 hover:-translate-y-1"> | |
| <header className="mb-5"> | |
| <h2 className="text-sm uppercase tracking-widest text-bone-300 flex items-center gap-2"> | |
| <span className="inline-block w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> | |
| Run the ranker | |
| </h2> | |
| <p className="text-xs text-bone-400 mt-2 max-w-2xl"> | |
| Drop your candidates file, or click <em>"Feed sample candidates"</em> to | |
| explore with the default 50-row fixture. Every upload is validated | |
| against the official Redrob schema first - mismatches surface as a | |
| git diff style report, not a stack trace. | |
| </p> | |
| </header> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-5"> | |
| <DropField | |
| label="Candidates (.json / .jsonl / .jsonl.gz)" | |
| dropzone={candidateDrop} | |
| file={candidates} | |
| onClear={() => setCandidates(null)} | |
| accent="bone" | |
| /> | |
| <DropField | |
| label="Job description (.txt / .md / .docx / .pdf) - optional" | |
| dropzone={jdDrop} | |
| file={jd} | |
| onClear={() => setJd(null)} | |
| accent="bone" | |
| hint={ | |
| jd | |
| ? `Will rank against your uploaded JD` | |
| : `If empty, the default Senior-AI-Engineer JD is used` | |
| } | |
| /> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-4 mb-6"> | |
| <label className="text-xs uppercase tracking-widest text-bone-300">Top‑K</label> | |
| <input | |
| type="number" | |
| min={1} | |
| max={100} | |
| value={topK} | |
| onChange={(e) => setTopK(Math.min(100, Math.max(1, Number(e.target.value) || 1)))} | |
| className="w-24 bg-ink-800/80 border hairline px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-bone-50 transition-colors" | |
| /> | |
| </div> | |
| <div className="flex flex-wrap gap-3"> | |
| <button | |
| className="btn-primary disabled:opacity-50 group" | |
| disabled={busy || !candidates} | |
| onClick={runUpload} | |
| > | |
| {busy && mode === "upload" ? ( | |
| <> | |
| <Spinner /> Ranking… | |
| </> | |
| ) : ( | |
| <> | |
| Rank uploaded pool | |
| <span className="transition-transform group-hover:translate-x-0.5">→</span> | |
| </> | |
| )} | |
| </button> | |
| <button | |
| className="btn-ghost relative overflow-hidden disabled:opacity-50 group" | |
| disabled={busy} | |
| onClick={runSample} | |
| title="Use the default 50-row sample to explore the UI" | |
| > | |
| {busy && mode === "sample" ? ( | |
| <> | |
| <Spinner /> Ranking sample… | |
| </> | |
| ) : ( | |
| <> | |
| <SparkleIcon /> Feed sample candidates | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| <AnimatePresence> | |
| {candidates && ( | |
| <motion.p | |
| key={candidates.name} | |
| initial={{ opacity: 0, y: 4 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0 }} | |
| className="mt-4 text-[11px] text-bone-400 font-mono" | |
| > | |
| Ready: <span className="text-bone-200">{candidates.name}</span> ( | |
| {(candidates.size / 1024).toFixed(1)} KB) | |
| </motion.p> | |
| )} | |
| </AnimatePresence> | |
| </section> | |
| ); | |
| }; | |
| // ───────────────────────── helpers ───────────────────────────────────────── | |
| const Spinner: React.FC = () => ( | |
| <span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" /> | |
| ); | |
| const SparkleIcon: React.FC = () => ( | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| <path d="M12 3l1.7 4.6 4.6 1.7-4.6 1.7L12 15.6 10.3 11l-4.6-1.7 4.6-1.7L12 3z" /> | |
| <path d="M19 14l1 2.6 2.6 1-2.6 1-1 2.6-1-2.6-2.6-1 2.6-1 1-2.6z" /> | |
| </svg> | |
| ); | |
| interface DropFieldProps { | |
| label: string; | |
| file: File | null; | |
| onClear: () => void; | |
| dropzone: ReturnType<typeof useDropzone>; | |
| accent: "bone" | "red"; | |
| hint?: string; | |
| } | |
| const DropField: React.FC<DropFieldProps> = ({ label, file, onClear, dropzone, hint }) => { | |
| const { getRootProps, getInputProps, isDragActive, isDragReject } = dropzone; | |
| return ( | |
| <div> | |
| <div className="text-[11px] uppercase tracking-widest text-bone-400 mb-2">{label}</div> | |
| <div | |
| {...getRootProps()} | |
| className={`relative card flex items-center justify-between px-4 py-3 cursor-pointer transition-all ${ | |
| isDragReject | |
| ? "border-red-400/60 bg-red-500/10" | |
| : isDragActive | |
| ? "border-bone-50/80 bg-bone-50/10 -translate-y-0.5" | |
| : "hover:border-bone-50/40" | |
| }`} | |
| > | |
| <input {...getInputProps()} /> | |
| <span className="font-mono text-xs truncate text-bone-200 max-w-[60%]"> | |
| {file ? file.name : isDragActive ? "drop to attach" : "drag or click to attach"} | |
| </span> | |
| {file ? ( | |
| <button | |
| type="button" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onClear(); | |
| }} | |
| className="btn-ghost text-[10px] px-2 py-1" | |
| > | |
| clear | |
| </button> | |
| ) : ( | |
| <span className="btn-ghost pointer-events-none">Browse</span> | |
| )} | |
| </div> | |
| {hint && ( | |
| <div className="text-[10px] text-bone-400 mt-1.5 italic font-mono"> | |
| {hint} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default RunPanel; | |