ketannnn commited on
Commit
7770c5f
·
1 Parent(s): 7bd1c28

feat: slider max dynamically set from CSV row count (header excluded)

Browse files
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
- # CRITICAL: Pre-load the 2.3 GB Neural Cross-Encoder
64
- # to entirely prevent HF Gateway 60-second 500 timeouts
65
- # during user requests.
66
- # -----------------------------------------------------
67
- import asyncio
68
- from src.ml.reranker import _get_reranker
69
- logger.info(f"Preloading Neural Reranker `{settings.reranker_model}`. This may take ~60 seconds to cache...")
70
- await asyncio.to_thread(_get_reranker)
71
- logger.info("Neural Reranker fully loaded into memory!")
 
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
- # Enforce small batch_size to prevent OOM kills on 100+ candidates in cloud environments
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
- // Pass EXACT candidate count as both Stage 1 and Stage 2 top_k
193
- await api.triggerMatch(jdId, sessionId, candidateCount, candidateCount);
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={e => setFile(e.target.files?.[0] || null)} />
 
 
 
 
 
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