ketannnn commited on
Commit
7649e72
·
1 Parent(s): ead8d3e

feat: implement automated ingestion pipeline with state persistence and add database maintenance utilities

Browse files
backend/clean_all.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from src.database import engine
3
+ from sqlalchemy import text
4
+ from src.config import get_settings
5
+ from qdrant_client import QdrantClient
6
+ from qdrant_client.models import Distance, VectorParams
7
+
8
+ async def main():
9
+ async with engine.begin() as conn:
10
+ print('Wiping Postgres...')
11
+ await conn.execute(text('DROP SCHEMA public CASCADE'))
12
+ await conn.execute(text('CREATE SCHEMA public'))
13
+ print('Postgres wiped.')
14
+
15
+ import qdrant_client
16
+ settings = get_settings()
17
+ try:
18
+ q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
19
+ q.delete_collection(settings.collection_name)
20
+ q.create_collection(settings.collection_name, vectors_config=VectorParams(size=384, distance=Distance.COSINE))
21
+ print('Qdrant wiped and recreated.')
22
+ except Exception as e:
23
+ print('Qdrant error:', e)
24
+
25
+ if __name__ == '__main__':
26
+ asyncio.run(main())
27
+
backend/clean_db.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from src.database import engine
3
+ from sqlalchemy import text
4
+ from src.config import get_settings
5
+ from qdrant_client import QdrantClient
6
+ from qdrant_client.models import Distance, VectorParams, PayloadSchemaType
7
+
8
+ async def main():
9
+ async with engine.begin() as conn:
10
+ print('Wiping Postgres data natively...')
11
+ await conn.execute(text('DROP SCHEMA public CASCADE'))
12
+ await conn.execute(text('CREATE SCHEMA public'))
13
+ print('Postgres schema wiped.')
14
+
15
+ import qdrant_client
16
+ settings = get_settings()
17
+ try:
18
+ q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
19
+ q.delete_collection(settings.collection_name)
20
+ q.create_collection(settings.collection_name, vectors_config=VectorParams(size=384, distance=Distance.COSINE))
21
+
22
+ # Reinject indices required natively by the pipeline
23
+ q.create_payload_index(
24
+ collection_name=settings.collection_name,
25
+ field_name="session_id",
26
+ field_schema=PayloadSchemaType.KEYWORD
27
+ )
28
+ q.create_payload_index(
29
+ collection_name=settings.collection_name,
30
+ field_name="years_of_experience",
31
+ field_schema=PayloadSchemaType.FLOAT
32
+ )
33
+ print('Qdrant collection wiped and re-indexed.')
34
+ except Exception as e:
35
+ print('Qdrant error:', e)
36
+
37
+ print("\n------------------------------")
38
+ print("Database is completely purged but empty.")
39
+ print("WARNING: You MUST now run the following command to rebuild the tables:")
40
+ print(" alembic upgrade head")
41
+ print("------------------------------")
42
+
43
+ if __name__ == '__main__':
44
+ asyncio.run(main())
backend/fix_qdrant.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from qdrant_client import QdrantClient
3
+ from src.config import get_settings
4
+
5
+ settings = get_settings()
6
+ q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
7
+
8
+ try:
9
+ q.create_payload_index(
10
+ collection_name=settings.collection_name,
11
+ field_name='session_id',
12
+ field_schema='keyword'
13
+ )
14
+ print('Payload index created successfully!')
15
+ except Exception as e:
16
+ print('Error creating payload index:', e)
17
+
backend/fix_qdrant2.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from qdrant_client import QdrantClient
3
+ from qdrant_client.models import PayloadSchemaType
4
+ from src.config import get_settings
5
+
6
+ settings = get_settings()
7
+ q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
8
+
9
+ try:
10
+ q.create_payload_index(
11
+ collection_name=settings.collection_name,
12
+ field_name='years_of_experience',
13
+ field_schema=PayloadSchemaType.FLOAT
14
+ )
15
+ print('Payload index for years_of_experience created successfully!')
16
+ except Exception as e:
17
+ print('Error creating payload index:', e)
18
+
backend/fix_qdrant_yoe.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from qdrant_client import QdrantClient
3
+ from src.config import get_settings
4
+
5
+ settings = get_settings()
6
+ q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
7
+
8
+ try:
9
+ q.create_payload_index(
10
+ collection_name=settings.collection_name,
11
+ field_name='years_of_experience',
12
+ field_schema='integer'
13
+ )
14
+ print('Years of Experience index created successfully!')
15
+ except Exception as e:
16
+ print('Error creating YOE index:', e)
17
+
backend/src/matching/stage1.py CHANGED
@@ -32,7 +32,7 @@ async def stage1_retrieve(
32
  db: AsyncSession,
33
  qdrant: QdrantClient,
34
  session_id: str | None = None,
35
- top_k: int = 200,
36
  weights: dict | None = None,
37
  ) -> list[dict[str, Any]]:
38
  settings = get_settings()
@@ -111,4 +111,4 @@ async def stage1_retrieve(
111
  })
112
 
113
  scored.sort(key=lambda x: x["stage1_score"], reverse=True)
114
- return scored[:50]
 
32
  db: AsyncSession,
33
  qdrant: QdrantClient,
34
  session_id: str | None = None,
35
+ top_k: int = 500,
36
  weights: dict | None = None,
37
  ) -> list[dict[str, Any]]:
38
  settings = get_settings()
 
111
  })
112
 
113
  scored.sort(key=lambda x: x["stage1_score"], reverse=True)
114
+ return scored[:250]
backend/src/routers/candidates.py CHANGED
@@ -73,10 +73,14 @@ async def upload_candidates(
73
  @router.get("/status/{task_id}", response_model=TaskStatusResponse)
74
  async def task_status(task_id: str):
75
  result = celery_app.AsyncResult(task_id)
 
 
 
 
76
  return TaskStatusResponse(
77
  task_id=task_id,
78
  status=result.status,
79
- result=result.result if result.ready() else None,
80
  )
81
 
82
 
 
73
  @router.get("/status/{task_id}", response_model=TaskStatusResponse)
74
  async def task_status(task_id: str):
75
  result = celery_app.AsyncResult(task_id)
76
+ res_data = result.result if result.ready() else None
77
+ if isinstance(res_data, Exception):
78
+ res_data = str(res_data)
79
+
80
  return TaskStatusResponse(
81
  task_id=task_id,
82
  status=result.status,
83
+ result=res_data,
84
  )
85
 
86
 
backend/src/routers/matching.py CHANGED
@@ -241,6 +241,24 @@ async def rerank_results(
241
  )
242
 
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  @router.get("/{jd_id}/{candidate_id}", response_model=CandidateDetailResponse)
245
  async def get_candidate_detail(
246
  jd_id: uuid.UUID,
 
241
  )
242
 
243
 
244
+ @router.post("/{jd_id}/candidates/{candidate_id}/explain")
245
+ async def trigger_explanation(
246
+ jd_id: uuid.UUID,
247
+ candidate_id: uuid.UUID,
248
+ session_id: uuid.UUID | None = Query(None),
249
+ db: AsyncSession = Depends(get_db),
250
+ ):
251
+ q = select(MatchResult).where(MatchResult.jd_id == jd_id, MatchResult.candidate_id == candidate_id)
252
+ if session_id:
253
+ q = q.where(MatchResult.session_id == session_id)
254
+ mr_result = await db.execute(q)
255
+ mr = mr_result.scalar_one_or_none()
256
+ if not mr:
257
+ raise HTTPException(status_code=404, detail="Match result not found")
258
+
259
+ generate_top_explanations.delay([str(mr.id)])
260
+ return {"status": "queued"}
261
+
262
  @router.get("/{jd_id}/{candidate_id}", response_model=CandidateDetailResponse)
263
  async def get_candidate_detail(
264
  jd_id: uuid.UUID,
frontend/src/app/globals.css CHANGED
@@ -45,22 +45,61 @@ body {
45
 
46
  input[type="range"] {
47
  -webkit-appearance: none;
48
- height: 4px;
 
 
 
49
  border-radius: 99px;
50
- background: var(--color-surface-2);
51
  outline: none;
52
  cursor: pointer;
 
53
  }
 
 
 
 
 
 
 
 
 
54
  input[type="range"]::-webkit-slider-thumb {
55
- -webkit-appearance: none;
56
  width: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  height: 16px;
 
58
  border-radius: 50%;
59
- background: var(--color-brand);
60
- box-shadow: 0 0 8px var(--color-brand-glow);
61
- transition: transform 0.15s;
 
 
 
 
 
62
  }
63
- input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
64
 
65
  @keyframes spin { to { transform: rotate(360deg); } }
66
  @keyframes slide-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
 
45
 
46
  input[type="range"] {
47
  -webkit-appearance: none;
48
+ appearance: none;
49
+ width: 100%;
50
+ height: 6px;
51
+ background: var(--color-border-strong);
52
  border-radius: 99px;
 
53
  outline: none;
54
  cursor: pointer;
55
+ margin: 10px 0;
56
  }
57
+
58
+ input[type="range"]::-webkit-slider-runnable-track {
59
+ width: 100%;
60
+ height: 6px;
61
+ cursor: pointer;
62
+ background: transparent;
63
+ border-radius: 99px;
64
+ }
65
+
66
  input[type="range"]::-webkit-slider-thumb {
67
+ height: 16px;
68
  width: 16px;
69
+ border-radius: 50%;
70
+ background: var(--thumb-color, var(--color-brand));
71
+ cursor: pointer;
72
+ -webkit-appearance: none;
73
+ margin-top: -5px; /* Centers thumb on the track */
74
+ box-shadow: 0 0 10px rgba(0,0,0,0.5), 0 0 0 2px var(--color-surface);
75
+ transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
76
+ }
77
+
78
+ input[type="range"]:focus::-webkit-slider-thumb {
79
+ box-shadow: 0 0 0 3px var(--color-brand-glow), 0 0 10px rgba(0,0,0,0.5);
80
+ }
81
+
82
+ input[type="range"]::-moz-range-track {
83
+ width: 100%;
84
+ height: 6px;
85
+ cursor: pointer;
86
+ background: var(--color-border-strong);
87
+ border-radius: 99px;
88
+ }
89
+
90
+ input[type="range"]::-moz-range-thumb {
91
  height: 16px;
92
+ width: 16px;
93
  border-radius: 50%;
94
+ background: var(--thumb-color, var(--color-brand));
95
+ cursor: pointer;
96
+ border: none;
97
+ box-shadow: 0 0 10px rgba(0,0,0,0.5), 0 0 0 2px var(--color-surface);
98
+ }
99
+
100
+ input[type="range"]::-webkit-slider-thumb:active {
101
+ transform: scale(1.2);
102
  }
 
103
 
104
  @keyframes spin { to { transform: rotate(360deg); } }
105
  @keyframes slide-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
frontend/src/app/jds/[id]/candidates/[cid]/page.tsx CHANGED
@@ -65,6 +65,28 @@ export default function CandidateDetailPage() {
65
  const [detail, setDetail] = useState<CandidateDetail | null>(null);
66
  const [loading, setLoading] = useState(true);
67
  const [error, setError] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  const loadDetail = useCallback(async () => {
70
  try {
@@ -161,9 +183,16 @@ export default function CandidateDetailPage() {
161
  </div>
162
  ) : (
163
  <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 flex flex-col items-center justify-center py-8 text-[var(--color-muted)]">
164
- <div className="mb-3"><SkipForward className="w-8 h-8 text-[var(--color-border-strong)]" /></div>
165
- <div className="font-semibold mb-1 text-[var(--color-text)]">LLM Generation Skipped</div>
166
- <div className="text-xs text-center">This candidate lies outside the Top-20 ranking slice. Deep-dive generation was intentionally skipped to conserve pipeline latency.</div>
 
 
 
 
 
 
 
167
  </div>
168
  )}
169
 
 
65
  const [detail, setDetail] = useState<CandidateDetail | null>(null);
66
  const [loading, setLoading] = useState(true);
67
  const [error, setError] = useState<string | null>(null);
68
+ const [generating, setGenerating] = useState(false);
69
+
70
+ const handleGenerateLLM = async () => {
71
+ try {
72
+ setGenerating(true);
73
+ await api.triggerExplanation(jdId, candidateId, sessionId);
74
+
75
+ const interval = setInterval(async () => {
76
+ const d = await api.getCandidateDetail(jdId, candidateId, sessionId).catch(() => null);
77
+ if (d && d.explanation) {
78
+ setDetail(d);
79
+ clearInterval(interval);
80
+ setGenerating(false);
81
+ }
82
+ }, 3500);
83
+
84
+ setTimeout(() => { clearInterval(interval); setGenerating(false); }, 60000);
85
+ } catch (e) {
86
+ console.error(e);
87
+ setGenerating(false);
88
+ }
89
+ };
90
 
91
  const loadDetail = useCallback(async () => {
92
  try {
 
183
  </div>
184
  ) : (
185
  <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 flex flex-col items-center justify-center py-8 text-[var(--color-muted)]">
186
+ <div className="mb-3"><SkipForward className="w-8 h-8 text-[var(--color-border-strong)]" /></div>
187
+ <div className="font-semibold mb-1 text-[var(--color-text)]">LLM Generation Skipped</div>
188
+ <div className="text-xs text-center mb-4">This candidate lies outside the Top-20 ranking slice. Deep-dive generation was intentionally skipped to conserve pipeline latency.</div>
189
+ <button
190
+ onClick={handleGenerateLLM}
191
+ disabled={generating}
192
+ className="px-4 py-2 bg-[var(--color-brand)] text-white text-xs font-bold rounded-lg hover:bg-[var(--color-brand-light)] transition disabled:opacity-50"
193
+ >
194
+ {generating ? "Generating via Groq..." : "Generate LLM Analysis"}
195
+ </button>
196
  </div>
197
  )}
198
 
frontend/src/app/jds/[id]/page.tsx CHANGED
@@ -32,21 +32,29 @@ export default function JDDetailPage() {
32
  const loadData = useCallback(async () => {
33
  const [jdData, sessionList] = await Promise.all([api.getJD(jdId), api.listSessions().catch(() => [])]);
34
  setJD(jdData);
35
- if (jdData && jdData.custom_weights && Object.keys(jdData.custom_weights).length > 0) {
36
- setWeights(jdData.custom_weights as typeof DEFAULT_WEIGHTS);
37
- } else {
38
- setWeights(DEFAULT_WEIGHTS);
39
- }
40
  setSessions(sessionList as SessionInfo[]);
41
- if (preselectedSession || sessionList.length === 1) {
42
- const sid = preselectedSession || (sessionList as SessionInfo[])[0]?.id;
43
- if (sid) {
44
- setSelectedSession(sid);
45
- api.getMatchResults(jdId, sid).then(r => {
46
- setMatch(r);
47
- setBaseResults(r.results);
48
- }).catch(() => {});
 
 
 
 
 
49
  }
 
 
 
 
 
 
 
 
50
  }
51
  setLoading(false);
52
  }, [jdId, preselectedSession]);
@@ -67,6 +75,10 @@ export default function JDDetailPage() {
67
  const nw = { ...weights, [key]: val };
68
  setWeights(nw);
69
 
 
 
 
 
70
  // Instance 0ms Array sorting natively!
71
  if (baseResults && match) {
72
  const totalW = Object.values(nw).reduce((a, b) => a + b, 0);
@@ -246,7 +258,8 @@ export default function JDDetailPage() {
246
  <input
247
  id={`weight-${key}`}
248
  type="range" min={0} max={1} step={0.01} value={val}
249
- style={{ width: "100%", accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
 
250
  onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
251
  />
252
  </div>
 
32
  const loadData = useCallback(async () => {
33
  const [jdData, sessionList] = await Promise.all([api.getJD(jdId), api.listSessions().catch(() => [])]);
34
  setJD(jdData);
 
 
 
 
 
35
  setSessions(sessionList as SessionInfo[]);
36
+
37
+ let sid = preselectedSession || (sessionList as SessionInfo[])[0]?.id;
38
+ let initialW = jdData?.custom_weights && Object.keys(jdData.custom_weights).length > 0
39
+ ? (jdData.custom_weights as typeof DEFAULT_WEIGHTS)
40
+ : DEFAULT_WEIGHTS;
41
+
42
+ if (sid) {
43
+ setSelectedSession(sid);
44
+ if (typeof window !== "undefined") {
45
+ const stored = localStorage.getItem(`coderound_w_${jdId}_${sid}`);
46
+ if (stored) {
47
+ try { initialW = JSON.parse(stored); } catch (e) {}
48
+ }
49
  }
50
+ setWeights(initialW);
51
+
52
+ api.getMatchResults(jdId, sid).then(r => {
53
+ setMatch(r);
54
+ setBaseResults(r.results);
55
+ }).catch(() => {});
56
+ } else {
57
+ setWeights(initialW);
58
  }
59
  setLoading(false);
60
  }, [jdId, preselectedSession]);
 
75
  const nw = { ...weights, [key]: val };
76
  setWeights(nw);
77
 
78
+ if (selectedSession && typeof window !== "undefined") {
79
+ localStorage.setItem(`coderound_w_${jdId}_${selectedSession}`, JSON.stringify(nw));
80
+ }
81
+
82
  // Instance 0ms Array sorting natively!
83
  if (baseResults && match) {
84
  const totalW = Object.values(nw).reduce((a, b) => a + b, 0);
 
258
  <input
259
  id={`weight-${key}`}
260
  type="range" min={0} max={1} step={0.01} value={val}
261
+ style={{ "--thumb-color": SCORE_COLORS[i % SCORE_COLORS.length] } as any}
262
+ className="cursor-pointer"
263
  onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
264
  />
265
  </div>
frontend/src/app/layout.tsx CHANGED
@@ -17,15 +17,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
17
  ⚡ TalentPulse
18
  </Link>
19
  <div className="flex items-center gap-1">
20
- <Link href="/" className="px-3 py-1.5 rounded-lg text-sm text-[var(--color-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)] transition-all">
21
- Dashboard
22
- </Link>
23
- <Link href="/sessions" className="px-3 py-1.5 rounded-lg text-sm text-[var(--color-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)] transition-all">
24
- Sessions
25
- </Link>
26
- <Link href="/jds" className="px-3 py-1.5 rounded-lg text-sm text-[var(--color-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)] transition-all">
27
- Job Descriptions
28
- </Link>
29
  <Link href="/pipeline" className="ml-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-[var(--color-brand-light)] bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] hover:bg-[var(--color-brand)] hover:text-white transition-all">
30
  ⚡ Auto Pipeline
31
  </Link>
 
17
  ⚡ TalentPulse
18
  </Link>
19
  <div className="flex items-center gap-1">
 
 
 
 
 
 
 
 
 
20
  <Link href="/pipeline" className="ml-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-[var(--color-brand-light)] bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] hover:bg-[var(--color-brand)] hover:text-white transition-all">
21
  ⚡ Auto Pipeline
22
  </Link>
frontend/src/app/page.tsx CHANGED
@@ -1,159 +1,5 @@
1
- "use client";
2
- import { useState, useEffect, useCallback } from "react";
3
- import Link from "next/link";
4
- import { api, type SessionInfo, type JD } from "../lib/api";
5
 
6
- export default function HomePage() {
7
- const [sessions, setSessions] = useState<SessionInfo[]>([]);
8
- const [jds, setJDs] = useState<JD[]>([]);
9
- const [totalCandidates, setTotalCandidates] = useState<number | null>(null);
10
- const [loading, setLoading] = useState(true);
11
-
12
- const loadData = useCallback(async () => {
13
- const [s, j, c] = await Promise.all([
14
- api.listSessions().catch(() => []),
15
- api.listJDs().catch(() => []),
16
- api.candidateCount().catch(() => ({ count: 0 })),
17
- ]);
18
- setSessions(s as SessionInfo[]);
19
- setJDs(j as JD[]);
20
- setTotalCandidates((c as { count: number }).count);
21
- setLoading(false);
22
- }, []);
23
-
24
- useEffect(() => { loadData(); }, [loadData]);
25
-
26
- const qualityColor = (q: string) =>
27
- q === "good" ? "text-green-400 bg-green-400/10 border-green-400/20"
28
- : q === "fair" ? "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"
29
- : "text-red-400 bg-red-400/10 border-red-400/20";
30
-
31
- return (
32
- <div className="max-w-7xl mx-auto px-6 py-12">
33
- <div className="text-center mb-14">
34
- <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] text-[var(--color-brand-light)] text-sm font-medium mb-5">
35
- ⚡ AI-Powered Recruiting Pipeline
36
- </div>
37
- <h1 className="text-5xl font-extrabold tracking-tight mb-4 leading-none">
38
- Match the{" "}
39
- <span className="bg-gradient-to-r from-[var(--color-brand-light)] via-purple-400 to-sky-400 bg-clip-text text-transparent">
40
- right talent
41
- </span>
42
- <br />at any scale
43
- </h1>
44
- <p className="text-[var(--color-muted)] text-lg max-w-xl mx-auto mb-8">
45
- Upload candidate sessions, post JDs, and get AI-ranked matches with LLM explanations in seconds.
46
- </p>
47
- <Link href="/pipeline" className="px-8 py-4 rounded-xl bg-[var(--color-brand)] text-white font-bold tracking-wide text-sm shadow-xl shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all hover:-translate-y-0.5">
48
- 🚀 Run Continuous Monolithic Pipeline
49
- </Link>
50
- </div>
51
-
52
- <div className="grid grid-cols-3 gap-5 mb-12">
53
- {[
54
- { val: totalCandidates !== null ? totalCandidates.toLocaleString() : "—", label: "Candidates Indexed", color: "from-[var(--color-brand-light)] to-purple-400" },
55
- { val: sessions.length, label: "Active Sessions", color: "from-green-400 to-emerald-300" },
56
- { val: jds.length, label: "Job Descriptions", color: "from-amber-400 to-yellow-300" },
57
- ].map(({ val, label, color }) => (
58
- <div key={label} className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6">
59
- <div className={`text-3xl font-bold tracking-tight bg-gradient-to-r ${color} bg-clip-text text-transparent mb-1`}>{val}</div>
60
- <div className="text-sm text-[var(--color-muted)]">{label}</div>
61
- </div>
62
- ))}
63
- </div>
64
-
65
- <div className="grid grid-cols-2 gap-8">
66
- <div>
67
- <div className="flex items-center justify-between mb-4">
68
- <h2 className="text-lg font-semibold">Recent Sessions</h2>
69
- <Link href="/sessions" className="text-sm text-[var(--color-brand-light)] hover:underline">View all →</Link>
70
- </div>
71
- {loading ? (
72
- <div className="space-y-3">{[1, 2, 3].map((i) => <div key={i} className="h-16 rounded-xl animate-shimmer" />)}</div>
73
- ) : sessions.length === 0 ? (
74
- <div className="bg-[var(--color-card)] border border-dashed border-[var(--color-border-strong)] rounded-2xl p-10 text-center">
75
- <div className="text-3xl mb-3">📁</div>
76
- <div className="font-medium mb-1">No sessions yet</div>
77
- <div className="text-sm text-[var(--color-muted)] mb-4">Upload candidates to a named session to get started</div>
78
- <Link href="/sessions/new" className="text-sm text-[var(--color-brand-light)] hover:underline">Create your first session →</Link>
79
- </div>
80
- ) : (
81
- <div className="space-y-3">
82
- {sessions.slice(0, 5).map((s) => (
83
- <Link key={s.id} href={`/sessions/${s.id}`}
84
- 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">
85
- <div className="w-10 h-10 rounded-xl bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] flex items-center justify-center text-lg flex-shrink-0">📁</div>
86
- <div className="flex-1 min-w-0">
87
- <div className="font-medium truncate">{s.name}</div>
88
- <div className="text-xs text-[var(--color-muted)]">{s.candidate_count.toLocaleString()} candidates</div>
89
- </div>
90
- <span className="text-[var(--color-dimmer)] group-hover:text-[var(--color-brand-light)] transition-colors">→</span>
91
- </Link>
92
- ))}
93
- </div>
94
- )}
95
- </div>
96
-
97
- <div>
98
- <div className="flex items-center justify-between mb-4">
99
- <h2 className="text-lg font-semibold">Recent Job Descriptions</h2>
100
- <Link href="/jds" className="text-sm text-[var(--color-brand-light)] hover:underline">View all →</Link>
101
- </div>
102
- {loading ? (
103
- <div className="space-y-3">{[1, 2, 3].map((i) => <div key={i} className="h-16 rounded-xl animate-shimmer" />)}</div>
104
- ) : jds.length === 0 ? (
105
- <div className="bg-[var(--color-card)] border border-dashed border-[var(--color-border-strong)] rounded-2xl p-10 text-center">
106
- <div className="text-3xl mb-3">📋</div>
107
- <div className="font-medium mb-1">No JDs yet</div>
108
- <div className="text-sm text-[var(--color-muted)] mb-4">Post a job description to start matching candidates</div>
109
- <Link href="/jds/new" className="text-sm text-[var(--color-brand-light)] hover:underline">Create your first JD →</Link>
110
- </div>
111
- ) : (
112
- <div className="space-y-3">
113
- {jds.slice(0, 5).map((jd) => (
114
- <Link key={jd.id} href={`/jds/${jd.id}`}
115
- 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">
116
- <div className="w-10 h-10 rounded-xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-lg flex-shrink-0">📋</div>
117
- <div className="flex-1 min-w-0">
118
- <div className="font-medium truncate">{jd.title}</div>
119
- <div className="text-xs text-[var(--color-muted)] flex gap-2">
120
- {jd.engineer_type && <span>{jd.engineer_type}</span>}
121
- {jd.min_yoe && <span>{jd.min_yoe}+ yrs</span>}
122
- </div>
123
- </div>
124
- <div className="flex items-center gap-2 flex-shrink-0">
125
- {jd.jd_quality?.overall && (
126
- <span className={`text-xs px-2 py-0.5 rounded-full border ${qualityColor(jd.jd_quality.overall)}`}>
127
- {jd.jd_quality.overall}
128
- </span>
129
- )}
130
- <span className="text-[var(--color-dimmer)] group-hover:text-[var(--color-brand-light)] transition-colors">→</span>
131
- </div>
132
- </Link>
133
- ))}
134
- </div>
135
- )}
136
- </div>
137
- </div>
138
-
139
- <div className="mt-14 bg-gradient-to-br from-[var(--color-brand-dim)] to-purple-900/10 border border-[var(--color-brand-glow)] rounded-2xl p-8">
140
- <h3 className="text-lg font-semibold mb-3">🛠 How it works</h3>
141
- <div className="grid grid-cols-4 gap-4">
142
- {[
143
- { icon: "📁", step: "1", title: "Create a Session", desc: "Upload your candidate CSV/JSON to a named batch" },
144
- { icon: "📋", step: "2", title: "Post a JD", desc: "Paste a job description — auto-parsed for skills & YOE" },
145
- { icon: "⚡", step: "3", title: "Run Match", desc: "Stage 1 ANN search + Stage 2 cross-encoder reranking" },
146
- { icon: "🤖", step: "4", title: "Get Explanations", desc: "LLM explains fit + gaps for every top candidate" },
147
- ].map(({ icon, step, title, desc }) => (
148
- <div key={step} className="text-center">
149
- <div className="text-2xl mb-2">{icon}</div>
150
- <div className="text-xs text-[var(--color-brand-light)] font-bold mb-1">STEP {step}</div>
151
- <div className="font-semibold text-sm mb-1">{title}</div>
152
- <div className="text-xs text-[var(--color-muted)]">{desc}</div>
153
- </div>
154
- ))}
155
- </div>
156
- </div>
157
- </div>
158
- );
159
  }
 
1
+ import { redirect } from 'next/navigation';
 
 
 
2
 
3
+ export default function Home() {
4
+ redirect('/pipeline');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  }
frontend/src/app/pipeline/page.tsx CHANGED
@@ -66,7 +66,10 @@ export default function PipelinePage() {
66
  localStorage.setItem("talentpulse_pipeline", JSON.stringify(state));
67
  if (!timerRef.current && state.startTime) {
68
  timerRef.current = setInterval(() => {
69
- setState(s => ({ ...s, elapsedTime: Math.floor((Date.now() - (s.startTime || Date.now())) / 1000) }));
 
 
 
70
  }, 1000);
71
  }
72
  } else {
@@ -78,9 +81,17 @@ export default function PipelinePage() {
78
  localStorage.removeItem("talentpulse_pipeline");
79
  }
80
  }
81
- return () => { if (timerRef.current) clearInterval(timerRef.current); };
82
  }, [state.status, state.startTime]);
83
 
 
 
 
 
 
 
 
 
 
84
  const updateState = (update: Partial<PipelineState>) => {
85
  setState(s => {
86
  const ns = { ...s, ...update };
 
66
  localStorage.setItem("talentpulse_pipeline", JSON.stringify(state));
67
  if (!timerRef.current && state.startTime) {
68
  timerRef.current = setInterval(() => {
69
+ setState(s => {
70
+ if (s.status === "idle" || s.status === "complete") return s;
71
+ return { ...s, elapsedTime: Math.floor((Date.now() - (s.startTime || Date.now())) / 1000) };
72
+ });
73
  }, 1000);
74
  }
75
  } else {
 
81
  localStorage.removeItem("talentpulse_pipeline");
82
  }
83
  }
 
84
  }, [state.status, state.startTime]);
85
 
86
+ useEffect(() => {
87
+ return () => {
88
+ if (timerRef.current) {
89
+ clearInterval(timerRef.current);
90
+ timerRef.current = null;
91
+ }
92
+ };
93
+ }, []);
94
+
95
  const updateState = (update: Partial<PipelineState>) => {
96
  setState(s => {
97
  const ns = { ...s, ...update };
frontend/src/app/sessions/[id]/page.tsx CHANGED
@@ -43,9 +43,17 @@ export default function SessionDetailPage({ params }: { params: Promise<{ id: st
43
  // If a JD is selected, load its match info automatically
44
  if (initialJdId) {
45
  const jdInfo = (jList as JD[]).find(x => x.id === initialJdId);
46
- if (jdInfo && jdInfo.custom_weights && Object.keys(jdInfo.custom_weights).length > 0) {
47
- setWeights(jdInfo.custom_weights as typeof DEFAULT_WEIGHTS);
 
 
 
 
 
 
 
48
  }
 
49
 
50
  try {
51
  const r = await api.getMatchResults(initialJdId, sessionId);
@@ -261,9 +269,15 @@ export default function SessionDetailPage({ params }: { params: Promise<{ id: st
261
  </div>
262
  <input
263
  type="range" min={0} max={1} step={0.01} value={val}
264
- style={{ accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
265
- className="w-full h-1.5 focus:outline-none"
266
- onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
 
 
 
 
 
 
267
  />
268
  </div>
269
  ))}
 
43
  // If a JD is selected, load its match info automatically
44
  if (initialJdId) {
45
  const jdInfo = (jList as JD[]).find(x => x.id === initialJdId);
46
+ let initialW = jdInfo?.custom_weights && Object.keys(jdInfo.custom_weights).length > 0
47
+ ? (jdInfo.custom_weights as typeof DEFAULT_WEIGHTS)
48
+ : DEFAULT_WEIGHTS;
49
+
50
+ if (typeof window !== "undefined") {
51
+ const stored = localStorage.getItem(`coderound_w_${initialJdId}_${sessionId}`);
52
+ if (stored) {
53
+ try { initialW = JSON.parse(stored); } catch (e) {}
54
+ }
55
  }
56
+ setWeights(initialW);
57
 
58
  try {
59
  const r = await api.getMatchResults(initialJdId, sessionId);
 
269
  </div>
270
  <input
271
  type="range" min={0} max={1} step={0.01} value={val}
272
+ style={{ "--thumb-color": SCORE_COLORS[i % SCORE_COLORS.length] } as any}
273
+ className="cursor-pointer"
274
+ onChange={(e) => {
275
+ const nw = { ...weights, [key]: parseFloat(e.target.value) };
276
+ if (selectedJD && typeof window !== "undefined") {
277
+ localStorage.setItem(`coderound_w_${selectedJD}_${sessionId}`, JSON.stringify(nw));
278
+ }
279
+ handleWeightChange(key, parseFloat(e.target.value));
280
+ }}
281
  />
282
  </div>
283
  ))}
frontend/src/app/sessions/page.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useCallback } from "react";
3
+ import Link from "next/link";
4
+ import { Users, Calendar, LayoutDashboard, Copy, CheckCircle2 } from "lucide-react";
5
+ import { api, type SessionInfo } from "../../lib/api";
6
+
7
+ export default function SessionsListPage() {
8
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const [copiedId, setCopiedId] = useState<string | null>(null);
12
+
13
+ const loadSessions = useCallback(async () => {
14
+ try {
15
+ setLoading(true);
16
+ const data = await api.listSessions();
17
+ setSessions(data);
18
+ } catch (e: any) {
19
+ setError(e.message);
20
+ } finally {
21
+ setLoading(false);
22
+ }
23
+ }, []);
24
+
25
+ useEffect(() => { loadSessions(); }, [loadSessions]);
26
+
27
+ const handleCopy = (e: React.MouseEvent, id: string) => {
28
+ e.preventDefault();
29
+ navigator.clipboard.writeText(id);
30
+ setCopiedId(id);
31
+ setTimeout(() => setCopiedId(null), 2000);
32
+ };
33
+
34
+ if (loading) return (
35
+ <div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
36
+ <div className="w-12 h-12 border-2 border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" />
37
+ <div>
38
+ <div className="font-semibold text-center mb-1">Loading Data Pools...</div>
39
+ <div className="text-sm text-[var(--color-muted)] text-center">Fetching available pipeline sessions</div>
40
+ </div>
41
+ </div>
42
+ );
43
+
44
+ if (error) return <div className="p-8 text-[var(--color-danger)] text-center font-semibold">Error: {error}</div>;
45
+
46
+ return (
47
+ <div className="max-w-7xl mx-auto px-6 py-10">
48
+ <div className="flex items-center justify-between mb-8">
49
+ <div>
50
+ <h1 className="text-3xl font-bold tracking-tight mb-2">Ingestion Sessions</h1>
51
+ <p className="text-[var(--color-muted)]">Select an ingestion pool below to interact with its associated candidate vectors.</p>
52
+ </div>
53
+ <Link href="/pipeline" className="flex items-center gap-2 px-4 py-2.5 bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] hover:border-[var(--color-brand-light)] hover:bg-[var(--color-brand-dim)] transition-all rounded-xl text-sm font-semibold">
54
+ <LayoutDashboard className="w-4 h-4" /> Run New Pipeline
55
+ </Link>
56
+ </div>
57
+
58
+ {sessions.length === 0 ? (
59
+ <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-10 text-center flex flex-col items-center justify-center">
60
+ <LayoutDashboard className="w-12 h-12 text-[var(--color-border-strong)] mb-4" />
61
+ <div className="text-lg font-bold mb-1">No Sessions Found</div>
62
+ <div className="text-sm text-[var(--color-muted)] mb-6">There are currently no active candidate data pools available.</div>
63
+ <Link href="/pipeline" className="px-6 py-2.5 bg-[var(--color-brand)] text-white text-sm font-bold rounded-xl hover:bg-[var(--color-brand-light)] transition">
64
+ Create First Session
65
+ </Link>
66
+ </div>
67
+ ) : (
68
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
69
+ {sessions.map((s) => (
70
+ <Link key={s.id} href={`/sessions/${s.id}`} className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-6 hover:shadow-lg hover:border-[var(--color-brand-light)] transition-all group block">
71
+ <div className="flex justify-between items-start mb-4">
72
+ <div className="bg-[var(--color-surface-2)] text-[var(--color-brand-light)] px-3 py-1.5 rounded-lg text-xs font-bold border border-[var(--color-border-strong)] flex items-center gap-2 tracking-wide uppercase">
73
+ <LayoutDashboard className="w-3.5 h-3.5" /> Pool
74
+ </div>
75
+ <div className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${s.status === "processing" ? "bg-yellow-500/10 text-yellow-500 border border-yellow-500/20 animate-pulse" : s.status === "failed" ? "bg-red-500/10 text-red-500 border border-red-500/20" : "bg-green-500/10 text-green-400 border border-green-500/20"}`}>
76
+ {s.status}
77
+ </div>
78
+ </div>
79
+
80
+ <h2 className="text-xl font-bold mb-2 text-[var(--color-text)] group-hover:text-[var(--color-brand-light)] transition-colors">{s.name}</h2>
81
+ <p className="text-sm text-[var(--color-muted)] mb-6 line-clamp-2">{s.description || "No description provided."}</p>
82
+
83
+ <div className="flex items-center gap-4 text-xs font-semibold text-[var(--color-dimmer)] pt-4 border-t border-[var(--color-border)]/50">
84
+ <span className="flex items-center gap-1.5 text-[var(--color-text)]">
85
+ <Users className="w-4 h-4 opacity-70 text-[var(--color-brand)]" />
86
+ {s.candidate_count} Candidates
87
+ </span>
88
+ <span className="flex items-center gap-1.5">
89
+ <Calendar className="w-4 h-4 opacity-70" />
90
+ {new Date(s.created_at).toLocaleDateString()}
91
+ </span>
92
+ </div>
93
+
94
+ <div className="mt-4 pt-4 border-t border-[var(--color-border)]/50 flex justify-between items-center gap-3">
95
+ <div className="text-[10px] font-mono text-[var(--color-dimmer)] truncate flex-1">{s.id}</div>
96
+ <button onClick={(e) => handleCopy(e, s.id)} className="p-1.5 rounded hover:bg-[var(--color-surface-2)] text-[var(--color-muted)] hover:text-[var(--color-text)] transition-colors">
97
+ {copiedId === s.id ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
98
+ </button>
99
+ </div>
100
+ </Link>
101
+ ))}
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }
frontend/src/lib/api.ts CHANGED
@@ -161,6 +161,13 @@ export const api = {
161
  return request<CandidateDetail>(url);
162
  },
163
 
 
 
 
 
 
 
 
164
  rerank: (jdId: string, weights: Record<string, number>, sessionId?: string) => {
165
  const url = sessionId
166
  ? `/api/match/${jdId}/rerank?session_id=${sessionId}`
 
161
  return request<CandidateDetail>(url);
162
  },
163
 
164
+ triggerExplanation: (jdId: string, candidateId: string, sessionId?: string) => {
165
+ const url = sessionId
166
+ ? `/api/match/${jdId}/candidates/${candidateId}/explain?session_id=${sessionId}`
167
+ : `/api/match/${jdId}/candidates/${candidateId}/explain`;
168
+ return request<{ status: string }>(url, { method: "POST" });
169
+ },
170
+
171
  rerank: (jdId: string, weights: Record<string, number>, sessionId?: string) => {
172
  const url = sessionId
173
  ? `/api/match/${jdId}/rerank?session_id=${sessionId}`