feat: slider max dynamically set from CSV row count (header excluded)
Browse files- backend/main.py +21 -11
- backend/src/ml/reranker.py +6 -1
- frontend/src/app/pipeline/page.tsx +72 -12
backend/main.py
CHANGED
|
@@ -11,9 +11,18 @@ from src.config import get_settings
|
|
| 11 |
from src.models import JobDescription, Candidate, MatchResult, Session
|
| 12 |
from src.routers import jds, candidates, matching, sessions, admin
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
settings = get_settings()
|
| 16 |
|
|
|
|
| 17 |
_qdrant_client: QdrantClient | None = None
|
| 18 |
_qdrant_ready: bool = False
|
| 19 |
|
|
@@ -58,17 +67,18 @@ async def lifespan(app: FastAPI):
|
|
| 58 |
|
| 59 |
app.state.qdrant = _qdrant_client
|
| 60 |
app.state.qdrant_ready = _qdrant_ready
|
| 61 |
-
|
| 62 |
-
# -
|
| 63 |
-
#
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
yield
|
| 74 |
_qdrant_client.close()
|
|
|
|
| 11 |
from src.models import JobDescription, Candidate, MatchResult, Session
|
| 12 |
from src.routers import jds, candidates, matching, sessions, admin
|
| 13 |
|
| 14 |
+
# Configure root logger so ALL module loggers (stage2, reranker, etc.)
|
| 15 |
+
# emit to stdout — critical for error visibility on Hugging Face
|
| 16 |
+
logging.basicConfig(
|
| 17 |
+
level=logging.INFO,
|
| 18 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 19 |
+
handlers=[logging.StreamHandler()],
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
settings = get_settings()
|
| 24 |
|
| 25 |
+
|
| 26 |
_qdrant_client: QdrantClient | None = None
|
| 27 |
_qdrant_ready: bool = False
|
| 28 |
|
|
|
|
| 67 |
|
| 68 |
app.state.qdrant = _qdrant_client
|
| 69 |
app.state.qdrant_ready = _qdrant_ready
|
| 70 |
+
|
| 71 |
+
# Pre-load the lightweight CrossEncoder (~80MB) eagerly at startup so the
|
| 72 |
+
# first matching request doesn't pay the cold-start download cost.
|
| 73 |
+
try:
|
| 74 |
+
import asyncio
|
| 75 |
+
from src.ml.reranker import _get_reranker
|
| 76 |
+
logger.info("Warming up Neural CrossEncoder reranker...")
|
| 77 |
+
await asyncio.to_thread(_get_reranker)
|
| 78 |
+
logger.info("Neural CrossEncoder loaded and ready!")
|
| 79 |
+
except Exception as warm_exc:
|
| 80 |
+
# Log but don't crash — matching will attempt lazy-load on first request
|
| 81 |
+
logger.warning(f"Reranker warm-up failed (will retry on first request): {warm_exc}")
|
| 82 |
|
| 83 |
yield
|
| 84 |
_qdrant_client.close()
|
backend/src/ml/reranker.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
|
|
| 1 |
from FlagEmbedding import FlagReranker
|
| 2 |
from ..config import get_settings
|
| 3 |
|
|
|
|
|
|
|
| 4 |
_reranker: FlagReranker | None = None
|
| 5 |
|
| 6 |
|
|
@@ -8,7 +11,9 @@ def _get_reranker() -> FlagReranker:
|
|
| 8 |
global _reranker
|
| 9 |
if _reranker is None:
|
| 10 |
settings = get_settings()
|
|
|
|
| 11 |
_reranker = FlagReranker(settings.reranker_model, use_fp16=False)
|
|
|
|
| 12 |
return _reranker
|
| 13 |
|
| 14 |
|
|
@@ -17,7 +22,7 @@ def rerank(query: str, passages: list[str]) -> list[float]:
|
|
| 17 |
return []
|
| 18 |
reranker = _get_reranker()
|
| 19 |
pairs = [[query, p] for p in passages]
|
| 20 |
-
#
|
| 21 |
scores = reranker.compute_score(pairs, normalize=True, batch_size=8)
|
| 22 |
if isinstance(scores, float):
|
| 23 |
scores = [scores]
|
|
|
|
| 1 |
+
import logging
|
| 2 |
from FlagEmbedding import FlagReranker
|
| 3 |
from ..config import get_settings
|
| 4 |
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
_reranker: FlagReranker | None = None
|
| 8 |
|
| 9 |
|
|
|
|
| 11 |
global _reranker
|
| 12 |
if _reranker is None:
|
| 13 |
settings = get_settings()
|
| 14 |
+
logger.info(f"[Reranker] Loading model: {settings.reranker_model}")
|
| 15 |
_reranker = FlagReranker(settings.reranker_model, use_fp16=False)
|
| 16 |
+
logger.info(f"[Reranker] Model loaded successfully: {settings.reranker_model}")
|
| 17 |
return _reranker
|
| 18 |
|
| 19 |
|
|
|
|
| 22 |
return []
|
| 23 |
reranker = _get_reranker()
|
| 24 |
pairs = [[query, p] for p in passages]
|
| 25 |
+
# batch_size=8 limits peak RAM usage — critical for cloud CPU-only environments
|
| 26 |
scores = reranker.compute_score(pairs, normalize=True, batch_size=8)
|
| 27 |
if isinstance(scores, float):
|
| 28 |
scores = [scores]
|
frontend/src/app/pipeline/page.tsx
CHANGED
|
@@ -38,6 +38,11 @@ export default function PipelinePage() {
|
|
| 38 |
const [sessionName, setSessionName] = useState("");
|
| 39 |
const [jds, setJds] = useState<JDInput[]>([{ title: "", desc: "" }]);
|
| 40 |
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
// Architecture state
|
| 43 |
const [state, setState] = useState<PipelineState>(DEFAULT_STATE);
|
|
@@ -100,6 +105,27 @@ export default function PipelinePage() {
|
|
| 100 |
});
|
| 101 |
};
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
const addJd = () => setJds([...jds, { title: "", desc: "" }]);
|
| 104 |
const removeJd = (idx: number) => {
|
| 105 |
if (jds.length === 1) return;
|
|
@@ -174,23 +200,14 @@ export default function PipelinePage() {
|
|
| 174 |
const runMatches = async (jdIds: string[], sessionId: string, currentState: PipelineState) => {
|
| 175 |
let pendingJds = [...jdIds];
|
| 176 |
|
| 177 |
-
// Fetch the ACTUAL number of ingested candidates — never hardcode a cap
|
| 178 |
-
let candidateCount = 100; // safe fallback
|
| 179 |
-
try {
|
| 180 |
-
const countRes = await api.candidateCount(sessionId);
|
| 181 |
-
candidateCount = countRes.count;
|
| 182 |
-
} catch (_) {
|
| 183 |
-
// If count fetch fails, fall back to 100
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
const pollMatches = async () => {
|
| 187 |
try {
|
| 188 |
const stillPending: string[] = [];
|
| 189 |
|
| 190 |
for (const jdId of pendingJds) {
|
| 191 |
try {
|
| 192 |
-
//
|
| 193 |
-
await api.triggerMatch(jdId, sessionId,
|
| 194 |
} catch (e: any) {
|
| 195 |
if (e.message === "202_ACCEPTED") {
|
| 196 |
stillPending.push(jdId);
|
|
@@ -294,7 +311,12 @@ export default function PipelinePage() {
|
|
| 294 |
<label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Candidates CSV (.csv, .json)</label>
|
| 295 |
<input type="file" accept=".csv,.json,.jsonl"
|
| 296 |
className="w-full text-sm text-[var(--color-muted)] file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-brand-dim)] file:text-[var(--color-brand-light)] hover:file:bg-[var(--color-brand)] hover:file:text-white transition-all cursor-pointer border border-[var(--color-border-strong)] rounded-xl p-2"
|
| 297 |
-
onChange={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
</div>
|
| 299 |
|
| 300 |
<div className="mb-6 border-t border-[var(--color-border-strong)] pt-6">
|
|
@@ -336,6 +358,44 @@ export default function PipelinePage() {
|
|
| 336 |
</div>
|
| 337 |
</div>
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
<button onClick={startPipeline}
|
| 340 |
className="w-full py-4 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all active:scale-[0.98]">
|
| 341 |
START AUTOMATED PIPELINE
|
|
|
|
| 38 |
const [sessionName, setSessionName] = useState("");
|
| 39 |
const [jds, setJds] = useState<JDInput[]>([{ title: "", desc: "" }]);
|
| 40 |
const [file, setFile] = useState<File | null>(null);
|
| 41 |
+
// csvRowCount: actual number of data rows in the uploaded CSV (excludes header row)
|
| 42 |
+
const [csvRowCount, setCsvRowCount] = useState<number>(0);
|
| 43 |
+
// Ranking cap: max candidates to pass through Stage 1 + Stage 2
|
| 44 |
+
// Default 72 — safe upper bound for BAAI/bge-reranker-v2-m3 on HF free tier
|
| 45 |
+
const [rankingCap, setRankingCap] = useState(72);
|
| 46 |
|
| 47 |
// Architecture state
|
| 48 |
const [state, setState] = useState<PipelineState>(DEFAULT_STATE);
|
|
|
|
| 105 |
});
|
| 106 |
};
|
| 107 |
|
| 108 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 109 |
+
const selected = e.target.files?.[0] || null;
|
| 110 |
+
setFile(selected);
|
| 111 |
+
if (!selected) {
|
| 112 |
+
setCsvRowCount(0);
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
const reader = new FileReader();
|
| 116 |
+
reader.onload = (ev) => {
|
| 117 |
+
const text = ev.target?.result as string;
|
| 118 |
+
if (!text) return;
|
| 119 |
+
// Count non-empty lines and subtract 1 for the header row
|
| 120 |
+
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
| 121 |
+
const dataRows = Math.max(0, lines.length - 1);
|
| 122 |
+
setCsvRowCount(dataRows);
|
| 123 |
+
// Clamp the current rankingCap to the new row count, keep it ≤ 72 initially
|
| 124 |
+
setRankingCap(prev => Math.min(prev, dataRows > 0 ? dataRows : 72));
|
| 125 |
+
};
|
| 126 |
+
reader.readAsText(selected);
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
const addJd = () => setJds([...jds, { title: "", desc: "" }]);
|
| 130 |
const removeJd = (idx: number) => {
|
| 131 |
if (jds.length === 1) return;
|
|
|
|
| 200 |
const runMatches = async (jdIds: string[], sessionId: string, currentState: PipelineState) => {
|
| 201 |
let pendingJds = [...jdIds];
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
const pollMatches = async () => {
|
| 204 |
try {
|
| 205 |
const stillPending: string[] = [];
|
| 206 |
|
| 207 |
for (const jdId of pendingJds) {
|
| 208 |
try {
|
| 209 |
+
// Use the user-configured ranking cap for both Stage 1 and Stage 2
|
| 210 |
+
await api.triggerMatch(jdId, sessionId, rankingCap, rankingCap);
|
| 211 |
} catch (e: any) {
|
| 212 |
if (e.message === "202_ACCEPTED") {
|
| 213 |
stillPending.push(jdId);
|
|
|
|
| 311 |
<label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Candidates CSV (.csv, .json)</label>
|
| 312 |
<input type="file" accept=".csv,.json,.jsonl"
|
| 313 |
className="w-full text-sm text-[var(--color-muted)] file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-brand-dim)] file:text-[var(--color-brand-light)] hover:file:bg-[var(--color-brand)] hover:file:text-white transition-all cursor-pointer border border-[var(--color-border-strong)] rounded-xl p-2"
|
| 314 |
+
onChange={handleFileChange} />
|
| 315 |
+
{csvRowCount > 0 && (
|
| 316 |
+
<p className="mt-2 text-xs text-[var(--color-muted)]">
|
| 317 |
+
📄 Detected <strong className="text-[var(--color-brand-light)]">{csvRowCount}</strong> candidate rows (excluding header)
|
| 318 |
+
</p>
|
| 319 |
+
)}
|
| 320 |
</div>
|
| 321 |
|
| 322 |
<div className="mb-6 border-t border-[var(--color-border-strong)] pt-6">
|
|
|
|
| 358 |
</div>
|
| 359 |
</div>
|
| 360 |
|
| 361 |
+
{/* RANKING CAP SLIDER */}
|
| 362 |
+
<div className="mb-6 border-t border-[var(--color-border-strong)] pt-6">
|
| 363 |
+
<div className="flex items-center justify-between mb-2">
|
| 364 |
+
<label className="text-sm font-bold text-[var(--color-text)]">Neural Ranking Cap</label>
|
| 365 |
+
<span className="text-lg font-mono font-bold text-[var(--color-brand-light)] bg-[var(--color-brand-dim)] px-3 py-1 rounded-lg border border-[var(--color-brand-glow)]">
|
| 366 |
+
{rankingCap}
|
| 367 |
+
</span>
|
| 368 |
+
</div>
|
| 369 |
+
<input
|
| 370 |
+
id="ranking-cap-slider"
|
| 371 |
+
type="range"
|
| 372 |
+
min={1}
|
| 373 |
+
max={csvRowCount > 0 ? csvRowCount : 200}
|
| 374 |
+
step={1}
|
| 375 |
+
value={rankingCap}
|
| 376 |
+
onChange={e => setRankingCap(Number(e.target.value))}
|
| 377 |
+
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
| 378 |
+
style={{
|
| 379 |
+
background: `linear-gradient(to right, var(--color-brand) ${
|
| 380 |
+
((rankingCap / (csvRowCount > 0 ? csvRowCount : 200)) * 100).toFixed(1)
|
| 381 |
+
}%, var(--color-border-strong) ${
|
| 382 |
+
((rankingCap / (csvRowCount > 0 ? csvRowCount : 200)) * 100).toFixed(1)
|
| 383 |
+
}%)`
|
| 384 |
+
}}
|
| 385 |
+
/>
|
| 386 |
+
<div className="flex justify-between text-[10px] text-[var(--color-muted)] mt-1">
|
| 387 |
+
<span>1</span>
|
| 388 |
+
<span>{csvRowCount > 0 ? csvRowCount : 200}</span>
|
| 389 |
+
</div>
|
| 390 |
+
{/* RAM Warning for BGE model */}
|
| 391 |
+
<div className="mt-3 flex items-start gap-2 bg-amber-500/10 border border-amber-500/25 rounded-xl px-4 py-3">
|
| 392 |
+
<span className="text-amber-400 text-sm mt-0.5">⚠️</span>
|
| 393 |
+
<p className="text-xs text-amber-300/90 leading-relaxed">
|
| 394 |
+
<strong>Hugging Face Free Tier Notice:</strong> We use <code className="font-mono bg-black/20 px-1 rounded">BAAI/bge-reranker-v2-m3</code> for neural reranking. On the free tier, this model exceeds available RAM above ~72 candidates and the backend will crash. <strong>Keep the cap at or below 72</strong> for stable results.
|
| 395 |
+
</p>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
<button onClick={startPipeline}
|
| 400 |
className="w-full py-4 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all active:scale-[0.98]">
|
| 401 |
START AUTOMATED PIPELINE
|