ketannnn commited on
Commit
5ff9d40
·
1 Parent(s): f25bf36

refactor: unified session view, pipeline polling, db wipe

Browse files
backend/src/routers/sessions.py CHANGED
@@ -25,7 +25,6 @@ async def list_sessions(db: AsyncSession = Depends(get_db)):
25
  result = await db.execute(select(Session).order_by(Session.created_at.desc()).limit(50))
26
  return result.scalars().all()
27
 
28
-
29
  @router.get("/{session_id}", response_model=SessionResponse)
30
  async def get_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
31
  result = await db.execute(select(Session).where(Session.id == session_id))
@@ -72,3 +71,4 @@ async def delete_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_d
72
 
73
  await db.delete(sess)
74
  await db.commit()
 
 
25
  result = await db.execute(select(Session).order_by(Session.created_at.desc()).limit(50))
26
  return result.scalars().all()
27
 
 
28
  @router.get("/{session_id}", response_model=SessionResponse)
29
  async def get_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
30
  result = await db.execute(select(Session).where(Session.id == session_id))
 
71
 
72
  await db.delete(sess)
73
  await db.commit()
74
+
backend/wipe.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from sqlalchemy.ext.asyncio import create_async_engine
4
+ from sqlalchemy import text
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+ url = os.getenv('DATABASE_URL').replace('postgresql:', 'postgresql+asyncpg:').replace('?sslmode=require', '')
9
+ if '?sslmode=require' not in url and 'ep-patient-brook' in url: pass # handle if needed
10
+
11
+ async def wipe():
12
+ engine = create_async_engine(url)
13
+ async with engine.begin() as conn:
14
+ await conn.execute(text('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'))
15
+ print('DB wiped')
16
+
17
+ if __name__ == '__main__':
18
+ asyncio.run(wipe())
19
+
backend/wipe_api.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from sqlalchemy.ext.asyncio import create_async_engine
3
+ from sqlalchemy import text
4
+ from dotenv import load_dotenv
5
+ import asyncio
6
+ import os
7
+
8
+ load_dotenv()
9
+ url = os.getenv('DATABASE_URL').replace('postgresql:', 'postgresql+asyncpg:').replace('?sslmode=require&channel_binding=require', '')
10
+ if '?sslmode=' not in url: url += '?ssl=require'
11
+
12
+ engine = create_async_engine(url)
13
+
14
+ async def main():
15
+ async with engine.begin() as conn:
16
+ print('Wiping...')
17
+ await conn.execute(text('TRUNCATE table job_descriptions, sessions, candidates, match_results CASCADE;'))
18
+ print('Wiped tables!')
19
+
20
+ if __name__ == '__main__':
21
+ asyncio.run(main())
22
+
frontend/src/app/pipeline/page.tsx CHANGED
@@ -159,14 +159,37 @@ export default function PipelinePage() {
159
  };
160
 
161
  const runMatches = async (jdIds: string[], sessionId: string, currentState: PipelineState) => {
162
- try {
163
- // Match each JD against the session in parallel
164
- await Promise.all(jdIds.map(jdId => api.triggerMatch(jdId, sessionId)));
165
- updateState({ status: "complete" });
166
- } catch (e: any) {
167
- setError("Matching failed: " + e.message);
168
- updateState({ status: "idle" });
169
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  };
171
 
172
  const formatTime = (sec: number) => {
@@ -303,7 +326,7 @@ export default function PipelinePage() {
303
  {state.jdsInfo.map((info, idx) => (
304
  <Link
305
  key={idx}
306
- href={`/jds/${state.jdIds[idx]}?session_id=${state.sessionId}`}
307
  className="flex justify-between items-center bg-[var(--color-card)] hover:bg-[var(--color-card-hover)] p-3 rounded-lg border border-[var(--color-border-strong)] hover:border-[var(--color-brand)] transition-all group"
308
  >
309
  <span className="font-semibold text-sm truncate pr-4">{info.title || `Job Description ${idx + 1}`}</span>
 
159
  };
160
 
161
  const runMatches = async (jdIds: string[], sessionId: string, currentState: PipelineState) => {
162
+ let pendingJds = [...jdIds];
163
+
164
+ const pollMatches = async () => {
165
+ try {
166
+ const stillPending: string[] = [];
167
+
168
+ for (const jdId of pendingJds) {
169
+ try {
170
+ await api.triggerMatch(jdId, sessionId);
171
+ } catch (e: any) {
172
+ if (e.message === "202_ACCEPTED") {
173
+ stillPending.push(jdId);
174
+ } else {
175
+ throw e;
176
+ }
177
+ }
178
+ }
179
+
180
+ if (stillPending.length > 0) {
181
+ pendingJds = stillPending;
182
+ setTimeout(pollMatches, 3000);
183
+ } else {
184
+ updateState({ status: "complete" });
185
+ }
186
+ } catch (e: any) {
187
+ setError("Matching failed: " + e.message);
188
+ updateState({ status: "idle" });
189
+ }
190
+ };
191
+
192
+ pollMatches();
193
  };
194
 
195
  const formatTime = (sec: number) => {
 
326
  {state.jdsInfo.map((info, idx) => (
327
  <Link
328
  key={idx}
329
+ href={`/sessions/${state.sessionId}?jd_id=${state.jdIds[idx]}`}
330
  className="flex justify-between items-center bg-[var(--color-card)] hover:bg-[var(--color-card-hover)] p-3 rounded-lg border border-[var(--color-border-strong)] hover:border-[var(--color-brand)] transition-all group"
331
  >
332
  <span className="font-semibold text-sm truncate pr-4">{info.title || `Job Description ${idx + 1}`}</span>
frontend/src/app/sessions/[id]/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
- import { useState, useEffect, useCallback } from "react";
3
- import { useParams, useRouter } from "next/navigation";
4
  import Link from "next/link";
5
  import { api, type SessionInfo, type JD, type MatchResponse, type MatchedCandidate } from "../../../lib/api";
6
 
@@ -11,257 +11,305 @@ const WEIGHT_LABELS: Record<string, string> = {
11
  company: "Company", growth: "Growth", education: "Education",
12
  };
13
 
14
- export default function SessionDetailPage() {
15
- const params = useParams<{ id: string }>();
16
- const sessionId = params.id;
17
  const router = useRouter();
 
 
18
 
19
  const [session, setSession] = useState<SessionInfo | null>(null);
20
  const [jds, setJDs] = useState<JD[]>([]);
21
- const [selectedJD, setSelectedJD] = useState<string | null>(null);
22
  const [match, setMatch] = useState<MatchResponse | null>(null);
 
23
  const [matching, setMatching] = useState(false);
24
  const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
25
  const [reranking, setReranking] = useState(false);
26
- const [uploading, setUploading] = useState(false);
27
- const [taskStatus, setTaskStatus] = useState<string | null>(null);
28
  const [loading, setLoading] = useState(true);
 
29
  const debounce = useCallback(<T extends unknown[]>(fn: (...args: T) => void, ms: number) => {
30
  let t: ReturnType<typeof setTimeout>;
31
  return (...args: T) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
32
  }, []);
33
 
34
  const loadData = useCallback(async () => {
35
- const [s, j] = await Promise.all([api.getSession(sessionId), api.listJDs().catch(() => [])]);
36
- setSession(s);
37
- setJDs(j as JD[]);
38
- setLoading(false);
39
- }, [sessionId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  useEffect(() => { loadData(); }, [loadData]);
42
 
43
- const handleMatch = async () => {
44
- if (!selectedJD) return;
45
- setMatching(true);
 
 
 
 
 
 
 
 
 
46
  try {
47
- const r = await api.triggerMatch(selectedJD, sessionId);
 
48
  setMatch(r);
49
- } catch (e: unknown) {
50
- alert((e as Error).message);
 
51
  } finally {
52
  setMatching(false);
53
  }
54
  };
55
 
56
- const handleWeightChange = useCallback(
57
- debounce(async (newWeights: typeof DEFAULT_WEIGHTS) => {
58
- if (!selectedJD || !match) return;
59
- setReranking(true);
60
- const r = await api.rerank(selectedJD, newWeights, sessionId).catch(() => null);
61
- if (r) setMatch(r);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  setReranking(false);
63
  }, 400),
64
- [selectedJD, match, sessionId, debounce]
65
  );
66
 
67
- const handleFileUpload = async (file: File) => {
68
- setUploading(true);
69
- try {
70
- const res = await api.uploadCandidates(file, sessionId);
71
- const taskId = res.task_id;
72
- setTaskStatus("PENDING");
73
- const poll = setInterval(async () => {
74
- const s = await api.taskStatus(taskId);
75
- setTaskStatus(s.status);
76
- if (s.status === "SUCCESS" || s.status === "FAILURE") {
77
- clearInterval(poll);
78
- loadData();
79
- setTaskStatus(null);
80
- }
81
- }, 2000);
82
- } finally {
83
- setUploading(false);
 
 
 
 
 
84
  }
 
 
85
  };
86
 
87
  const scoreColor = (s: number) => s >= 0.7 ? "#22c55e" : s >= 0.45 ? "#eab308" : "#ef4444";
88
- const rankClass = (r: number) => r === 1 ? "from-amber-400 to-amber-600" : r === 2 ? "from-slate-400 to-slate-500" : r === 3 ? "from-amber-700 to-amber-800" : null;
89
 
90
  if (loading) return (
91
- <div className="flex items-center justify-center min-h-[60vh]">
92
- <div className="text-center">
93
- <div className="w-10 h-10 border-2 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
94
- <div className="text-[var(--color-muted)]">Loading session…</div>
95
- </div>
96
  </div>
97
  );
98
 
99
- if (!session) return <div className="p-8 text-[var(--color-danger)]">Session not found</div>;
 
100
 
101
  return (
102
- <div className="max-w-7xl mx-auto px-6 py-10">
103
- <div className="flex items-start justify-between mb-8 gap-4 flex-wrap">
 
104
  <div>
105
- <Link href="/sessions" className="text-xs text-[var(--color-dimmer)] hover:text-[var(--color-muted)] mb-3 inline-block transition-colors">← All Sessions</Link>
106
- <h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
107
- <span className="text-2xl">📁</span>
108
- {session.name}
109
- </h1>
110
- {session.description && <p className="text-[var(--color-muted)] mt-1">{session.description}</p>}
111
- <div className="flex items-center gap-3 mt-3">
112
- <span className="text-2xl font-bold text-[var(--color-brand-light)]">{session.candidate_count.toLocaleString()}</span>
113
- <span className="text-sm text-[var(--color-muted)]">candidates indexed</span>
114
- </div>
115
  </div>
116
- <div className="flex gap-2">
117
- <button
118
- className="px-4 py-2 rounded-xl border border-red-500/30 text-sm font-medium text-red-500 cursor-pointer hover:bg-red-500/10 transition-all flex items-center gap-2"
119
- onClick={async () => {
120
- if (confirm("Are you sure? This will delete the session, all candidates inside it, and all related match results perfectly to clear database memory.")) {
121
- try {
122
- await api.deleteSession(sessionId);
123
- router.push("/");
124
- } catch (e: any) {
125
- alert("Failed to delete session: " + e.message);
126
- }
127
- }
128
- }}
129
- >
130
- 🗑 Delete Session
131
- </button>
132
- <label className="px-4 py-2 rounded-xl border border-[var(--color-border-strong)] text-sm font-medium cursor-pointer hover:border-[var(--color-brand)] hover:bg-[var(--color-card)] transition-all flex items-center gap-2">
133
- {uploading ? <><div className="w-3 h-3 border border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" /> Uploading…</> : "📤 Add Candidates"}
134
- <input type="file" accept=".csv,.json,.jsonl" className="hidden" onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])} />
135
- </label>
136
  </div>
137
  </div>
138
 
139
- {taskStatus && taskStatus !== "SUCCESS" && (
140
- <div className="mb-6 flex items-center gap-3 text-sm text-[var(--color-muted)] bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] rounded-xl px-5 py-3.5">
141
- <div className="w-4 h-4 border-2 border-[var(--color-brand-light)] border-t-transparent rounded-full animate-spin" />
142
- Embedding and indexing candidates ({taskStatus})
143
- </div>
144
- )}
145
-
146
- <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 mb-8">
147
- <h2 className="text-base font-semibold mb-4"> Run a Job Match Against This Session</h2>
148
- <div className="flex gap-3 flex-wrap items-end">
149
- <div className="flex-1 min-w-[240px]">
150
- <label className="text-xs text-[var(--color-muted)] mb-1.5 block">Select Job Description</label>
151
- <select
152
- id="jd-select"
153
- className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl px-4 py-2.5 text-sm outline-none focus:border-[var(--color-brand)] transition-all text-[var(--color-text)]"
154
- value={selectedJD || ""}
155
- onChange={(e) => setSelectedJD(e.target.value || null)}
156
- >
157
- <option value="">— Choose a JD —</option>
158
- {jds.map((j) => (
159
- <option key={j.id} value={j.id}>{j.title}</option>
160
- ))}
161
- </select>
 
 
162
  </div>
163
- <Link href="/jds/new" className="px-4 py-2.5 rounded-xl border border-[var(--color-border-strong)] text-sm text-[var(--color-muted)] hover:border-[var(--color-brand)] transition-all whitespace-nowrap">
164
- + New JD
165
- </Link>
166
- <button
167
- id="run-match-btn"
168
- className="px-6 py-2.5 rounded-xl bg-[var(--color-brand)] text-white font-semibold text-sm disabled:opacity-50 hover:brightness-110 transition-all flex items-center gap-2"
169
- onClick={handleMatch}
170
- disabled={!selectedJD || matching}
171
- >
172
- {matching ? <><div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> Matching…</> : "⚡ Run Match"}
173
- </button>
174
  </div>
175
- </div>
176
 
177
- {match && (
178
- <div className="grid grid-cols-[1fr_300px] gap-6">
179
- <div>
180
- <div className="flex items-center gap-3 mb-4">
181
- <h2 className="text-base font-semibold">Results for <span className="text-[var(--color-brand-light)]">{match.jd_title}</span></h2>
182
- <span className="text-xs text-[var(--color-dimmer)] px-2 py-0.5 rounded bg-[var(--color-surface-2)]">{match.total_matched} ranked</span>
183
- {reranking && <div className="w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" />}
184
  </div>
185
- <div className="space-y-3">
186
- {match.results.map((c) => (
187
- <Link
188
- key={c.candidate_id}
189
- href={`/jds/${selectedJD}/candidates/${c.candidate_id}?session_id=${sessionId}`}
190
- className="flex items-center gap-4 p-4 bg-[var(--color-card)] border border-[var(--color-border)] rounded-xl hover:border-[var(--color-brand)] hover:shadow-[0_0_20px_var(--color-brand-dim)] transition-all group"
191
- >
192
- <div className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 text-white ${rankClass(c.rank) ? `bg-gradient-to-br ${rankClass(c.rank)}` : "bg-[var(--color-surface-2)] text-[var(--color-muted)]"}`}>
193
- {c.rank}
194
- </div>
195
- <div className="flex-1 min-w-0">
196
- <div className="font-medium text-sm">{c.name || "Anonymous"}</div>
197
- <div className="text-xs text-[var(--color-muted)] flex gap-2 mt-0.5">
198
- {c.years_of_experience != null && <span>{c.years_of_experience} yrs</span>}
199
- {c.most_recent_company && <span>🏢 {c.most_recent_company}</span>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
201
- <div className="flex gap-1 mt-1.5 flex-wrap">
202
- {c.programming_languages.slice(0, 4).map((l) => (
203
- <span key={l} className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[var(--color-muted)]">{l}</span>
 
 
 
 
 
 
 
 
 
 
 
204
  ))}
205
  </div>
206
- </div>
207
- <div className="text-center flex-shrink-0">
208
- <div className="text-xl font-bold" style={{ color: scoreColor(c.final_score) }}>
209
- {(c.final_score * 100).toFixed(0)}
210
- </div>
211
- <div className="text-[10px] text-[var(--color-dimmer)]">score</div>
212
- </div>
213
- <span className="text-[var(--color-dimmer)] group-hover:text-[var(--color-brand-light)] transition-colors">→</span>
214
- </Link>
215
- ))}
216
- </div>
217
- </div>
218
 
219
- <div className="space-y-4 sticky top-20 self-start">
220
- <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-xl p-5">
221
- <div className="text-sm font-semibold mb-1">🎛 Scoring Weights</div>
222
- <div className="text-xs text-[var(--color-dimmer)] mb-4">Drag to re-rank instantly</div>
223
- <div className="space-y-4">
224
- {Object.entries(weights).map(([key, val], i) => (
225
- <div key={key}>
226
- <div className="flex justify-between text-xs mb-1.5">
227
- <span className="text-[var(--color-muted)]">{WEIGHT_LABELS[key]}</span>
228
- <span className="font-semibold text-[var(--color-brand-light)]">{(val * 100).toFixed(0)}%</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  </div>
230
- <input
231
- id={`w-${key}`}
232
- type="range" min={0} max={1} step={0.01} value={val}
233
- style={{ width: "100%", accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
234
- onChange={(e) => {
235
- const nw = { ...weights, [key]: parseFloat(e.target.value) };
236
- setWeights(nw);
237
- handleWeightChange(nw);
238
- }}
239
- />
240
- </div>
241
  ))}
242
  </div>
243
  </div>
244
-
245
- {match.jd_quality && (
246
- <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-xl p-5">
247
- <div className="text-sm font-semibold mb-4">📊 JD Quality</div>
248
- {match.jd_quality.warnings?.map((w, i) => (
249
- <div key={i} className="text-xs text-yellow-400 bg-yellow-400/10 border border-yellow-400/20 rounded-lg px-3 py-2 mb-2">⚠ {w}</div>
250
- ))}
251
- <div className="text-xs text-[var(--color-muted)]">Skills detected: <strong className="text-[var(--color-text)]">{match.jd_quality.skill_count}</strong></div>
252
- </div>
253
- )}
254
- </div>
255
  </div>
256
- )}
257
-
258
- {!match && !matching && (
259
- <div className="text-center py-20 bg-[var(--color-card)] border border-dashed border-[var(--color-border-strong)] rounded-2xl">
260
- <div className="text-4xl mb-3">🎯</div>
261
- <div className="font-semibold mb-2">Ready to match</div>
262
- <div className="text-sm text-[var(--color-muted)]">Select a JD above and click Run Match to start the pipeline</div>
263
- </div>
264
- )}
265
  </div>
266
  );
267
  }
 
1
  "use client";
2
+ import { useState, useEffect, useCallback, use } from "react";
3
+ import { useRouter, useSearchParams } from "next/navigation";
4
  import Link from "next/link";
5
  import { api, type SessionInfo, type JD, type MatchResponse, type MatchedCandidate } from "../../../lib/api";
6
 
 
11
  company: "Company", growth: "Growth", education: "Education",
12
  };
13
 
14
+ export default function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) {
15
+ const unwrappedParams = use(params);
16
+ const sessionId = unwrappedParams.id;
17
  const router = useRouter();
18
+ const searchParams = useSearchParams();
19
+ const initialJdId = searchParams.get("jd_id");
20
 
21
  const [session, setSession] = useState<SessionInfo | null>(null);
22
  const [jds, setJDs] = useState<JD[]>([]);
23
+ const [selectedJD, setSelectedJD] = useState<string | null>(initialJdId);
24
  const [match, setMatch] = useState<MatchResponse | null>(null);
25
+ const [baseResults, setBaseResults] = useState<MatchResponse["results"] | null>(null);
26
  const [matching, setMatching] = useState(false);
27
  const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
28
  const [reranking, setReranking] = useState(false);
 
 
29
  const [loading, setLoading] = useState(true);
30
+
31
  const debounce = useCallback(<T extends unknown[]>(fn: (...args: T) => void, ms: number) => {
32
  let t: ReturnType<typeof setTimeout>;
33
  return (...args: T) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
34
  }, []);
35
 
36
  const loadData = useCallback(async () => {
37
+ try {
38
+ const [s, jList] = await Promise.all([api.getSession(sessionId), api.listJDs()]);
39
+ setSession(s);
40
+ setJDs(jList);
41
+
42
+ // If a JD is selected, load its match info automatically
43
+ if (initialJdId) {
44
+ const jdInfo = (jList as JD[]).find(x => x.id === initialJdId);
45
+ if (jdInfo && jdInfo.custom_weights && Object.keys(jdInfo.custom_weights).length > 0) {
46
+ setWeights(jdInfo.custom_weights as typeof DEFAULT_WEIGHTS);
47
+ }
48
+
49
+ api.getMatchResults(initialJdId, sessionId).then(r => {
50
+ setMatch(r);
51
+ setBaseResults(r.results);
52
+ }).catch(() => {
53
+ setMatch(null);
54
+ setBaseResults(null);
55
+ });
56
+ }
57
+ } catch (e) {
58
+ console.error(e);
59
+ } finally {
60
+ setLoading(false);
61
+ }
62
+ }, [sessionId, initialJdId]);
63
 
64
  useEffect(() => { loadData(); }, [loadData]);
65
 
66
+ const selectJD = async (jd: JD) => {
67
+ setSelectedJD(jd.id);
68
+ router.replace(`/sessions/${sessionId}?jd_id=${jd.id}`, { scroll: false });
69
+ setMatch(null);
70
+ setBaseResults(null);
71
+
72
+ if (jd.custom_weights && Object.keys(jd.custom_weights).length > 0) {
73
+ setWeights(jd.custom_weights as typeof DEFAULT_WEIGHTS);
74
+ } else {
75
+ setWeights(DEFAULT_WEIGHTS);
76
+ }
77
+
78
  try {
79
+ setMatching(true);
80
+ const r = await api.getMatchResults(jd.id, sessionId);
81
  setMatch(r);
82
+ setBaseResults(r.results);
83
+ } catch {
84
+ // 404 meaning no results yet
85
  } finally {
86
  setMatching(false);
87
  }
88
  };
89
 
90
+ const handleMatch = async () => {
91
+ if (!selectedJD) return;
92
+ setMatching(true);
93
+ let attempts = 0;
94
+
95
+ const poll = async () => {
96
+ try {
97
+ const r = await api.triggerMatch(selectedJD, sessionId);
98
+ setMatch(r);
99
+ setBaseResults(r.results);
100
+ setMatching(false);
101
+ } catch (e: any) {
102
+ if (e.message === "202_ACCEPTED") {
103
+ attempts++;
104
+ if (attempts > 20) {
105
+ alert("Processing is taking longer than expected. Please check back later.");
106
+ setMatching(false);
107
+ } else {
108
+ setTimeout(poll, 3000);
109
+ }
110
+ } else {
111
+ alert("Matching error: " + e.message);
112
+ setMatching(false);
113
+ }
114
+ }
115
+ };
116
+ poll();
117
+ };
118
+
119
+ const saveWeightsToDb = useCallback(
120
+ debounce((jdId: string, nw: typeof DEFAULT_WEIGHTS) => {
121
+ api.updateJDWeights(jdId, nw).catch(() => null);
122
  setReranking(false);
123
  }, 400),
124
+ []
125
  );
126
 
127
+ const handleWeightChange = (key: string, val: number) => {
128
+ if (!selectedJD) return;
129
+ setReranking(true);
130
+ const nw = { ...weights, [key]: val };
131
+ setWeights(nw);
132
+
133
+ // Instant local rerank Native O(N log N)
134
+ if (baseResults && match) {
135
+ const totalW = Object.values(nw).reduce((a, b) => a + b, 0);
136
+ const wNorm: Record<string, number> = totalW > 0 ? Object.fromEntries(Object.entries(nw).map(([k, v]) => [k, v / totalW])) : nw;
137
+
138
+ const newRes = baseResults.map(c => {
139
+ let score = 0;
140
+ const cs = c.component_scores as unknown as Record<string, number>;
141
+ ["semantic", "skill", "yoe", "company", "growth", "education"].forEach(k => {
142
+ score += (cs[k] || 0) * (wNorm[k] || 0);
143
+ });
144
+ return { ...c, final_score: score };
145
+ });
146
+ newRes.sort((a, b) => b.final_score - a.final_score);
147
+ newRes.forEach((c, idx) => c.rank = idx + 1);
148
+ setMatch({ ...match, results: newRes });
149
  }
150
+
151
+ saveWeightsToDb(selectedJD, nw);
152
  };
153
 
154
  const scoreColor = (s: number) => s >= 0.7 ? "#22c55e" : s >= 0.45 ? "#eab308" : "#ef4444";
155
+ const rankClass = (r: number) => r <= 3 ? "bg-gradient-to-br from-amber-400 to-amber-600 border border-amber-300/50" : "bg-[var(--color-surface-2)] text-[var(--color-muted)]";
156
 
157
  if (loading) return (
158
+ <div className="flex justify-center items-center h-[60vh]">
159
+ <div className="w-10 h-10 border-2 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" />
 
 
 
160
  </div>
161
  );
162
 
163
+ if (!session) return <div className="p-8 text-red-500">Session not found. It may have been deleted.</div>;
164
+ const currentJdObj = jds.find(j => j.id === selectedJD);
165
 
166
  return (
167
+ <div className="max-w-[1500px] mx-auto px-6 py-8">
168
+ {/* HEADER */}
169
+ <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 mb-6 flex justify-between items-center shadow-sm">
170
  <div>
171
+ <Link href="/sessions" className="text-xs text-[var(--color-dimmer)] hover:text-[var(--color-text)] transition-colors mb-2 inline-block">← All Sessions</Link>
172
+ <h1 className="text-3xl font-bold tracking-tight">📁 {session.name}</h1>
173
+ <p className="text-sm text-[var(--color-muted)] mt-1">{session.description || "Generated Session Sandbox"}</p>
 
 
 
 
 
 
 
174
  </div>
175
+ <div className="text-right">
176
+ <div className="text-3xl font-bold text-[var(--color-brand-light)]">{session.candidate_count.toLocaleString()}</div>
177
+ <div className="text-xs text-[var(--color-muted)] uppercase tracking-wider font-semibold">Candidates Uploaded</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  </div>
179
  </div>
180
 
181
+ <div className="grid grid-cols-[320px_1fr] gap-6 items-start">
182
+ {/* LEFT SIDEBAR: JDs */}
183
+ <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl overflow-hidden sticky top-6 shadow-sm">
184
+ <div className="px-5 py-4 border-b border-[var(--color-border-strong)] flex justify-between items-center bg-[var(--color-surface-2)]">
185
+ <h2 className="text-sm font-bold text-[var(--color-text)]">Select JD to View</h2>
186
+ </div>
187
+ <div className="max-h-[calc(100vh-250px)] overflow-y-auto p-3 space-y-2">
188
+ {jds.length === 0 && <div className="p-4 text-xs text-center text-[var(--color-muted)]">No JDs created yet</div>}
189
+ {jds.map(jd => {
190
+ const active = jd.id === selectedJD;
191
+ return (
192
+ <button
193
+ key={jd.id}
194
+ onClick={() => selectJD(jd)}
195
+ className={`w-full text-left p-4 rounded-xl border transition-all ${
196
+ active
197
+ ? "bg-[var(--color-brand-dim)] border-[var(--color-brand-glow)] shadow-[0_0_15px_var(--color-brand-dim)]"
198
+ : "bg-[var(--color-card)] border-[var(--color-border)] hover:border-[var(--color-brand-dim)]"
199
+ }`}
200
+ >
201
+ <div className={`font-semibold text-sm ${active ? "text-[var(--color-brand-light)]" : "text-[var(--color-text)]"}`}>{jd.title}</div>
202
+ <div className="text-xs text-[var(--color-muted)] mt-1 truncate">Status: {jd.status}</div>
203
+ </button>
204
+ );
205
+ })}
206
  </div>
 
 
 
 
 
 
 
 
 
 
 
207
  </div>
 
208
 
209
+ {/* MAIN RANKING AREA */}
210
+ <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 min-h-[500px] shadow-sm">
211
+ {!selectedJD ? (
212
+ <div className="flex flex-col items-center justify-center h-full text-center opacity-50 py-32">
213
+ <div className="text-6xl mb-4">👈</div>
214
+ <h3 className="text-xl font-bold">Select a Job Description</h3>
215
+ <p className="text-sm mt-2">Filter and rank candidates by choosing a JD from the sidebar.</p>
216
  </div>
217
+ ) : matching ? (
218
+ <div className="flex flex-col items-center justify-center h-full text-center py-32 animate-fade-in">
219
+ <div className="w-12 h-12 border-4 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin mb-4" />
220
+ <h3 className="font-semibold text-lg">Running Neural Dual-Stage Match...</h3>
221
+ <p className="text-sm text-[var(--color-muted)] mt-1">Sifting through vector embeddings & generating insights.</p>
222
+ </div>
223
+ ) : !match ? (
224
+ <div className="flex flex-col items-center justify-center h-full text-center py-32 animate-fade-in">
225
+ <div className="text-5xl mb-4">🎯</div>
226
+ <h3 className="text-xl font-bold mb-2">No Match Matrix Found</h3>
227
+ <p className="text-sm text-[var(--color-muted)] mb-6 max-w-sm">
228
+ You haven't run the candidates against "{currentJdObj?.title}" yet.
229
+ </p>
230
+ <button onClick={handleMatch} className="px-6 py-3 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide shadow-lg hover:brightness-110 active:scale-95 transition-all">
231
+ Execute Match Pipeline
232
+ </button>
233
+ </div>
234
+ ) : (
235
+ <div className="animate-fade-in">
236
+ <div className="flex justify-between items-start mb-8 gap-6 border-b border-[var(--color-border-strong)] pb-6">
237
+ <div>
238
+ <h2 className="text-2xl font-bold tracking-tight mb-2">Results: {match.jd_title}</h2>
239
+ <div className="flex items-center gap-3">
240
+ <span className="text-sm text-[var(--color-muted)] px-3 py-1 rounded-full bg-[var(--color-surface-2)] font-medium">
241
+ {match.total_matched} candidates ranked
242
+ </span>
243
+ {reranking && <span className="text-xs text-[var(--color-brand-light)] flex items-center gap-2"><div className="w-3 h-3 border-2 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" /> Reranking instantly...</span>}
244
+ </div>
245
+ </div>
246
+
247
+ {/* Live Weights */}
248
+ <div className="bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl p-4 w-[350px] shadow-inner relative">
249
+ <div className="text-[10px] uppercase font-bold text-[var(--color-muted)] mb-3 tracking-wider flex justify-between">
250
+ <span>Live Prioritization Weights</span>
251
+ <span className="text-[var(--color-brand-light)]">Persists securely</span>
252
  </div>
253
+ <div className="grid grid-cols-2 gap-x-4 gap-y-3">
254
+ {Object.entries(weights).map(([key, val], i) => (
255
+ <div key={key}>
256
+ <div className="flex justify-between text-[10px] mb-1">
257
+ <span className="text-[var(--color-muted)]">{WEIGHT_LABELS[key]}</span>
258
+ <span className="font-semibold">{(val * 100).toFixed(0)}%</span>
259
+ </div>
260
+ <input
261
+ type="range" min={0} max={1} step={0.01} value={val}
262
+ style={{ accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
263
+ className="w-full h-1.5 focus:outline-none"
264
+ onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
265
+ />
266
+ </div>
267
  ))}
268
  </div>
269
+ </div>
270
+ </div>
 
 
 
 
 
 
 
 
 
 
271
 
272
+ <div className="space-y-3">
273
+ {match.results.map((c) => (
274
+ <Link
275
+ key={c.candidate_id}
276
+ href={`/jds/${selectedJD}/candidates/${c.candidate_id}?session_id=${sessionId}`}
277
+ className="flex items-center justify-between p-4 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-xl hover:border-[var(--color-brand-light)] hover:bg-[var(--color-card)] hover:shadow-lg transition-all group"
278
+ >
279
+ <div className="flex items-center gap-5">
280
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shadow-sm ${rankClass(c.rank)}`}>
281
+ {c.rank}
282
+ </div>
283
+ <div>
284
+ <div className="font-bold text-sm tracking-wide text-[var(--color-text)] mb-1">{c.name || "Anonymous Candidate"}</div>
285
+ <div className="text-xs text-[var(--color-muted)] flex items-center gap-3">
286
+ {c.years_of_experience != null && <span className="flex items-center gap-1">⏱ {c.years_of_experience} yrs</span>}
287
+ {c.most_recent_company && <span className="flex items-center gap-1">🏢 {c.most_recent_company}</span>}
288
+ </div>
289
+ </div>
290
+ </div>
291
+ <div className="flex items-center gap-8">
292
+ <div className="flex gap-1.5">
293
+ {c.programming_languages.slice(0, 3).map((l) => (
294
+ <span key={l} className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--color-border-strong)] text-[var(--color-text)] border border-[var(--color-border)]">{l}</span>
295
+ ))}
296
+ {c.programming_languages.length > 3 && <span className="text-[10px] px-2 py-0.5 text-[var(--color-muted)]">+{c.programming_languages.length - 3}</span>}
297
+ </div>
298
+
299
+ <div className="text-right w-16">
300
+ <div className="text-2xl font-bold tracking-tighter" style={{ color: scoreColor(c.final_score) }}>
301
+ {(c.final_score * 100).toFixed(0)}
302
+ </div>
303
+ <div className="text-[9px] uppercase tracking-widest text-[var(--color-dimmer)]">Match</div>
304
+ </div>
305
  </div>
306
+ </Link>
 
 
 
 
 
 
 
 
 
 
307
  ))}
308
  </div>
309
  </div>
310
+ )}
 
 
 
 
 
 
 
 
 
 
311
  </div>
312
+ </div>
 
 
 
 
 
 
 
 
313
  </div>
314
  );
315
  }
frontend/src/lib/api.ts CHANGED
@@ -10,6 +10,9 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
10
  const err = await res.json().catch(() => ({ detail: res.statusText }));
11
  throw new Error(err.detail || "Request failed");
12
  }
 
 
 
13
  return res.json();
14
  }
15
 
 
10
  const err = await res.json().catch(() => ({ detail: res.statusText }));
11
  throw new Error(err.detail || "Request failed");
12
  }
13
+ if (res.status === 202) {
14
+ throw new Error("202_ACCEPTED");
15
+ }
16
  return res.json();
17
  }
18