ketannnn commited on
Commit
a0f1a88
·
1 Parent(s): f61f6a3

feat: add pipeline page, session delete with Qdrant cleanup, explain worker, and final frontend polish

Browse files
backend/alembic/versions/0fe9eb85a0c8_add_custom_weights_to_jd.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add custom weights to JD
2
+
3
+ Revision ID: 0fe9eb85a0c8
4
+ Revises: 002
5
+ Create Date: 2026-04-13 13:07:25.196427
6
+ """
7
+ from typing import Sequence, Union
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.dialects import postgresql
11
+
12
+ revision: str = '0fe9eb85a0c8'
13
+ down_revision: Union[str, None] = '002'
14
+ branch_labels: Union[str, Sequence[str], None] = None
15
+ depends_on: Union[str, Sequence[str], None] = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ # ### commands auto generated by Alembic - please adjust! ###
20
+ op.alter_column('candidates', 'programming_languages',
21
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
22
+ nullable=False)
23
+ op.alter_column('candidates', 'backend_frameworks',
24
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
25
+ nullable=False)
26
+ op.alter_column('candidates', 'frontend_technologies',
27
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
28
+ nullable=False)
29
+ op.alter_column('candidates', 'parsed_work_experience',
30
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
31
+ nullable=False)
32
+ op.alter_column('candidates', 'created_at',
33
+ existing_type=postgresql.TIMESTAMP(timezone=True),
34
+ nullable=False,
35
+ existing_server_default=sa.text('now()'))
36
+ op.alter_column('candidates', 'updated_at',
37
+ existing_type=postgresql.TIMESTAMP(timezone=True),
38
+ nullable=False,
39
+ existing_server_default=sa.text('now()'))
40
+ op.add_column('job_descriptions', sa.Column('custom_weights', sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")))
41
+ op.alter_column('job_descriptions', 'parsed_requirements',
42
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
43
+ nullable=False)
44
+ op.alter_column('job_descriptions', 'required_skills',
45
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
46
+ nullable=False)
47
+ op.alter_column('job_descriptions', 'jd_quality',
48
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
49
+ nullable=False)
50
+ op.alter_column('job_descriptions', 'created_at',
51
+ existing_type=postgresql.TIMESTAMP(timezone=True),
52
+ nullable=False,
53
+ existing_server_default=sa.text('now()'))
54
+ op.alter_column('job_descriptions', 'updated_at',
55
+ existing_type=postgresql.TIMESTAMP(timezone=True),
56
+ nullable=False,
57
+ existing_server_default=sa.text('now()'))
58
+ op.alter_column('match_results', 'component_scores',
59
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
60
+ nullable=False)
61
+ op.alter_column('match_results', 'gaps',
62
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
63
+ nullable=False)
64
+ op.alter_column('match_results', 'explanation',
65
+ existing_type=sa.TEXT(),
66
+ type_=sa.String(),
67
+ existing_nullable=True)
68
+ op.alter_column('match_results', 'weights_used',
69
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
70
+ nullable=False)
71
+ op.alter_column('match_results', 'created_at',
72
+ existing_type=postgresql.TIMESTAMP(timezone=True),
73
+ nullable=False,
74
+ existing_server_default=sa.text('now()'))
75
+ op.alter_column('sessions', 'created_at',
76
+ existing_type=postgresql.TIMESTAMP(timezone=True),
77
+ nullable=False,
78
+ existing_server_default=sa.text('now()'))
79
+ op.alter_column('sessions', 'updated_at',
80
+ existing_type=postgresql.TIMESTAMP(timezone=True),
81
+ nullable=False,
82
+ existing_server_default=sa.text('now()'))
83
+ # ### end Alembic commands ###
84
+
85
+
86
+ def downgrade() -> None:
87
+ # ### commands auto generated by Alembic - please adjust! ###
88
+ op.alter_column('sessions', 'updated_at',
89
+ existing_type=postgresql.TIMESTAMP(timezone=True),
90
+ nullable=True,
91
+ existing_server_default=sa.text('now()'))
92
+ op.alter_column('sessions', 'created_at',
93
+ existing_type=postgresql.TIMESTAMP(timezone=True),
94
+ nullable=True,
95
+ existing_server_default=sa.text('now()'))
96
+ op.alter_column('match_results', 'created_at',
97
+ existing_type=postgresql.TIMESTAMP(timezone=True),
98
+ nullable=True,
99
+ existing_server_default=sa.text('now()'))
100
+ op.alter_column('match_results', 'weights_used',
101
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
102
+ nullable=True)
103
+ op.alter_column('match_results', 'explanation',
104
+ existing_type=sa.String(),
105
+ type_=sa.TEXT(),
106
+ existing_nullable=True)
107
+ op.alter_column('match_results', 'gaps',
108
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
109
+ nullable=True)
110
+ op.alter_column('match_results', 'component_scores',
111
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
112
+ nullable=True)
113
+ op.alter_column('job_descriptions', 'updated_at',
114
+ existing_type=postgresql.TIMESTAMP(timezone=True),
115
+ nullable=True,
116
+ existing_server_default=sa.text('now()'))
117
+ op.alter_column('job_descriptions', 'created_at',
118
+ existing_type=postgresql.TIMESTAMP(timezone=True),
119
+ nullable=True,
120
+ existing_server_default=sa.text('now()'))
121
+ op.alter_column('job_descriptions', 'jd_quality',
122
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
123
+ nullable=True)
124
+ op.alter_column('job_descriptions', 'required_skills',
125
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
126
+ nullable=True)
127
+ op.alter_column('job_descriptions', 'parsed_requirements',
128
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
129
+ nullable=True)
130
+ op.drop_column('job_descriptions', 'custom_weights')
131
+ op.alter_column('candidates', 'updated_at',
132
+ existing_type=postgresql.TIMESTAMP(timezone=True),
133
+ nullable=True,
134
+ existing_server_default=sa.text('now()'))
135
+ op.alter_column('candidates', 'created_at',
136
+ existing_type=postgresql.TIMESTAMP(timezone=True),
137
+ nullable=True,
138
+ existing_server_default=sa.text('now()'))
139
+ op.alter_column('candidates', 'parsed_work_experience',
140
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
141
+ nullable=True)
142
+ op.alter_column('candidates', 'frontend_technologies',
143
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
144
+ nullable=True)
145
+ op.alter_column('candidates', 'backend_frameworks',
146
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
147
+ nullable=True)
148
+ op.alter_column('candidates', 'programming_languages',
149
+ existing_type=postgresql.JSON(astext_type=sa.Text()),
150
+ nullable=True)
151
+ # ### end Alembic commands ###
backend/src/config.py CHANGED
@@ -1,5 +1,6 @@
1
  from pydantic_settings import BaseSettings, SettingsConfigDict
2
  from functools import lru_cache
 
3
 
4
 
5
  class Settings(BaseSettings):
@@ -16,7 +17,20 @@ class Settings(BaseSettings):
16
  collection_name: str = "candidates_v1"
17
  vector_size: int = 384
18
 
 
 
 
 
19
 
20
  @lru_cache
21
  def get_settings() -> Settings:
22
  return Settings()
 
 
 
 
 
 
 
 
 
 
1
  from pydantic_settings import BaseSettings, SettingsConfigDict
2
  from functools import lru_cache
3
+ import itertools
4
 
5
 
6
  class Settings(BaseSettings):
 
17
  collection_name: str = "candidates_v1"
18
  vector_size: int = 384
19
 
20
+ @property
21
+ def groq_keys_list(self) -> list[str]:
22
+ return [k.strip() for k in self.groq_api_key.split(",") if k.strip()]
23
+
24
 
25
  @lru_cache
26
  def get_settings() -> Settings:
27
  return Settings()
28
+
29
+ # Global memory state for circular revolving queue inside this worker thread
30
+ _groq_key_cycler = None
31
+
32
+ def get_revolving_groq_key() -> str:
33
+ global _groq_key_cycler
34
+ if _groq_key_cycler is None:
35
+ _groq_key_cycler = itertools.cycle(get_settings().groq_keys_list)
36
+ return next(_groq_key_cycler)
backend/src/database.py CHANGED
@@ -27,6 +27,9 @@ def _make_async_url(url: str) -> tuple[str, dict]:
27
  ctx.check_hostname = False
28
  ctx.verify_mode = _ssl.CERT_NONE
29
  connect_args["ssl"] = ctx
 
 
 
30
  return url, connect_args
31
 
32
 
@@ -37,6 +40,8 @@ engine = create_async_engine(
37
  echo=False,
38
  pool_pre_ping=True,
39
  connect_args=_connect_args,
 
 
40
  )
41
  AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
42
 
 
27
  ctx.check_hostname = False
28
  ctx.verify_mode = _ssl.CERT_NONE
29
  connect_args["ssl"] = ctx
30
+
31
+ # Crucial for Neon Serverless PgBouncer functionality
32
+ connect_args["statement_cache_size"] = 0
33
  return url, connect_args
34
 
35
 
 
40
  echo=False,
41
  pool_pre_ping=True,
42
  connect_args=_connect_args,
43
+ # This disables SQLAlchemy's internal prepared statement cache globally
44
+ execution_options={"prepared_statement_cache_size": 0}
45
  )
46
  AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
47
 
backend/src/matching/llm_explainer.py CHANGED
@@ -1,5 +1,5 @@
1
  from groq import AsyncGroq
2
- from ..config import get_settings
3
 
4
 
5
  SYSTEM_PROMPT = """You are a senior technical recruiter with deep engineering knowledge.
@@ -45,7 +45,7 @@ Provide your assessment:"""
45
 
46
  async def generate_explanation(jd: dict, candidate: dict, gaps: list[dict]) -> str:
47
  settings = get_settings()
48
- client = AsyncGroq(api_key=settings.groq_api_key)
49
 
50
  try:
51
  response = await client.chat.completions.create(
 
1
  from groq import AsyncGroq
2
+ from ..config import get_settings, get_revolving_groq_key
3
 
4
 
5
  SYSTEM_PROMPT = """You are a senior technical recruiter with deep engineering knowledge.
 
45
 
46
  async def generate_explanation(jd: dict, candidate: dict, gaps: list[dict]) -> str:
47
  settings = get_settings()
48
+ client = AsyncGroq(api_key=get_revolving_groq_key())
49
 
50
  try:
51
  response = await client.chat.completions.create(
backend/src/models/jd.py CHANGED
@@ -21,6 +21,7 @@ class JobDescription(Base):
21
  location: Mapped[str | None] = mapped_column(String(255), nullable=True)
22
  remote_allowed: Mapped[bool | None] = mapped_column(nullable=True)
23
  jd_quality: Mapped[dict] = mapped_column(JSON, default=dict)
 
24
  embedding_text: Mapped[str | None] = mapped_column(Text, nullable=True)
25
  qdrant_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
26
  status: Mapped[str] = mapped_column(String(32), default="pending")
 
21
  location: Mapped[str | None] = mapped_column(String(255), nullable=True)
22
  remote_allowed: Mapped[bool | None] = mapped_column(nullable=True)
23
  jd_quality: Mapped[dict] = mapped_column(JSON, default=dict)
24
+ custom_weights: Mapped[dict] = mapped_column(JSON, default=dict)
25
  embedding_text: Mapped[str | None] = mapped_column(Text, nullable=True)
26
  qdrant_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
27
  status: Mapped[str] = mapped_column(String(32), default="pending")
backend/src/routers/jds.py CHANGED
@@ -41,3 +41,20 @@ async def get_jd(jd_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
41
  if not jd:
42
  raise HTTPException(status_code=404, detail="JD not found")
43
  return jd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  if not jd:
42
  raise HTTPException(status_code=404, detail="JD not found")
43
  return jd
44
+
45
+
46
+ from pydantic import BaseModel
47
+ class JDWeightsUpdate(BaseModel):
48
+ weights: dict[str, float]
49
+
50
+ @router.patch("/{jd_id}/weights", response_model=JDResponse)
51
+ async def update_jd_weights(jd_id: uuid.UUID, payload: JDWeightsUpdate, db: AsyncSession = Depends(get_db)):
52
+ result = await db.execute(select(JobDescription).where(JobDescription.id == jd_id))
53
+ jd = result.scalar_one_or_none()
54
+ if not jd:
55
+ raise HTTPException(status_code=404, detail="JD not found")
56
+
57
+ jd.custom_weights = payload.weights
58
+ await db.commit()
59
+ await db.refresh(jd)
60
+ return jd
backend/src/routers/matching.py CHANGED
@@ -87,6 +87,9 @@ async def trigger_match(
87
  )
88
  )
89
 
 
 
 
90
  for i, item in enumerate(final_ranked):
91
  mr = MatchResult(
92
  id=uuid.uuid4(), jd_id=jd_id,
@@ -100,9 +103,15 @@ async def trigger_match(
100
  gaps=item.get("gaps", []),
101
  )
102
  db.add(mr)
 
103
 
104
  await db.commit()
105
 
 
 
 
 
 
106
  results = [_to_matched_candidate(item, i + 1) for i, item in enumerate(final_ranked)]
107
  return MatchResponse(
108
  jd_id=jd_id, jd_title=jd.title,
@@ -152,11 +161,15 @@ async def get_match_results(
152
  "final_score": mr.final_score,
153
  "component_scores": mr.component_scores or {}, "gaps": mr.gaps or [],
154
  }
155
- results.append(_to_matched_candidate(item, mr.rank or 0))
 
 
 
 
156
 
157
  return MatchResponse(
158
- jd_id=jd_id, jd_title=jd.title, jd_quality=jd.jd_quality or {},
159
- total_matched=len(results), results=results, session_id=session_id,
160
  )
161
 
162
 
@@ -168,6 +181,10 @@ async def rerank_results(
168
  db: AsyncSession = Depends(get_db),
169
  ):
170
  jd = await _load_jd(jd_id, db)
 
 
 
 
171
 
172
  q = (
173
  select(MatchResult, Candidate)
 
87
  )
88
  )
89
 
90
+ from ..workers.explain import generate_top_explanations
91
+
92
+ inserted_mrs = []
93
  for i, item in enumerate(final_ranked):
94
  mr = MatchResult(
95
  id=uuid.uuid4(), jd_id=jd_id,
 
103
  gaps=item.get("gaps", []),
104
  )
105
  db.add(mr)
106
+ inserted_mrs.append(mr)
107
 
108
  await db.commit()
109
 
110
+ # Pre-generate LLM explanations async for the top 60 matches
111
+ top_60_ids = [str(mr.id) for mr in inserted_mrs[:60]]
112
+ if top_60_ids:
113
+ generate_top_explanations.delay(top_60_ids)
114
+
115
  results = [_to_matched_candidate(item, i + 1) for i, item in enumerate(final_ranked)]
116
  return MatchResponse(
117
  jd_id=jd_id, jd_title=jd.title,
 
161
  "final_score": mr.final_score,
162
  "component_scores": mr.component_scores or {}, "gaps": mr.gaps or [],
163
  }
164
+ results.append(item)
165
+
166
+ # Automatically transform the database RRF fallback score into the correct % parameter scale
167
+ reranked = rerank_with_weights(results, jd.custom_weights or {})
168
+ final_results = [_to_matched_candidate(item, item["rank"]) for item in reranked]
169
 
170
  return MatchResponse(
171
+ jd_id=jd_id, jd_title=jd.title, jd_quality=jd.jd_quality or {}, weights_used=jd.custom_weights or {},
172
+ total_matched=len(results), results=final_results, session_id=session_id,
173
  )
174
 
175
 
 
181
  db: AsyncSession = Depends(get_db),
182
  ):
183
  jd = await _load_jd(jd_id, db)
184
+
185
+ # Save custom weights into the database asynchronously!
186
+ jd.custom_weights = payload.weights
187
+ await db.commit()
188
 
189
  q = (
190
  select(MatchResult, Candidate)
backend/src/routers/sessions.py CHANGED
@@ -35,11 +35,40 @@ async def get_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_db))
35
  return sess
36
 
37
 
 
 
 
 
 
 
 
 
 
38
  @router.delete("/{session_id}", status_code=204)
39
  async def delete_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
40
  result = await db.execute(select(Session).where(Session.id == session_id))
41
  sess = result.scalar_one_or_none()
42
  if not sess:
43
  raise HTTPException(status_code=404, detail="Session not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  await db.delete(sess)
45
  await db.commit()
 
35
  return sess
36
 
37
 
38
+ from qdrant_client import QdrantClient
39
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
40
+ from ..config import get_settings
41
+ from ..models.match_result import MatchResult
42
+
43
+ def _get_qdrant() -> QdrantClient:
44
+ settings = get_settings()
45
+ return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
46
+
47
  @router.delete("/{session_id}", status_code=204)
48
  async def delete_session(session_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
49
  result = await db.execute(select(Session).where(Session.id == session_id))
50
  sess = result.scalar_one_or_none()
51
  if not sess:
52
  raise HTTPException(status_code=404, detail="Session not found")
53
+
54
+ session_str = str(session_id)
55
+ settings = get_settings()
56
+
57
+ try:
58
+ qdrant = _get_qdrant()
59
+ qdrant.delete(
60
+ collection_name=settings.collection_name,
61
+ points_selector=Filter(
62
+ must=[
63
+ FieldCondition(key="session_id", match=MatchValue(value=session_str))
64
+ ]
65
+ )
66
+ )
67
+ except Exception as e:
68
+ print(f"Warning: Failed deleting Qdrant targets for session {session_str}: {e}")
69
+
70
+ await db.execute(MatchResult.__table__.delete().where(MatchResult.session_id == session_id))
71
+ await db.execute(Candidate.__table__.delete().where(Candidate.session_id == session_id))
72
+
73
  await db.delete(sess)
74
  await db.commit()
backend/src/schemas/jd.py CHANGED
@@ -20,6 +20,7 @@ class JDResponse(BaseModel):
20
  location: str | None = None
21
  required_skills: list[str] = []
22
  jd_quality: dict[str, Any] = {}
 
23
  created_at: datetime
24
 
25
  model_config = {"from_attributes": True}
 
20
  location: str | None = None
21
  required_skills: list[str] = []
22
  jd_quality: dict[str, Any] = {}
23
+ custom_weights: dict[str, float] = {}
24
  created_at: datetime
25
 
26
  model_config = {"from_attributes": True}
backend/src/workers/celery_app.py CHANGED
@@ -8,7 +8,7 @@ celery_app = Celery(
8
  "talentpulse",
9
  broker=settings.redis_url,
10
  backend=settings.redis_url,
11
- include=["src.workers.ingest"],
12
  )
13
 
14
  celery_app.conf.update(
 
8
  "talentpulse",
9
  broker=settings.redis_url,
10
  backend=settings.redis_url,
11
+ include=["src.workers.ingest", "src.workers.explain"],
12
  )
13
 
14
  celery_app.conf.update(
backend/src/workers/explain.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from typing import Any
3
+ from sqlalchemy import select
4
+ from .celery_app import celery_app
5
+ from ..database import AsyncSessionLocal
6
+ from ..models.match_result import MatchResult
7
+ from ..models.candidate import Candidate
8
+ from ..models.jd import JobDescription
9
+ from ..routers.matching import _build_jd_dict
10
+ from ..matching.llm_explainer import generate_explanation
11
+ import datetime
12
+
13
+ @celery_app.task(bind=True, name="generate_top_explanations", max_retries=1)
14
+ def generate_top_explanations(self, match_result_ids: list[str]) -> dict:
15
+ try:
16
+ return asyncio.run(_generate_top_explanations_async(match_result_ids))
17
+ except Exception as exc:
18
+ raise self.retry(exc=exc, countdown=10)
19
+
20
+ async def _generate_top_explanations_async(match_result_ids: list[str]) -> dict:
21
+ async with AsyncSessionLocal() as db:
22
+ # Load all the match results alongside candidate and JD data
23
+ q = (
24
+ select(MatchResult, Candidate, JobDescription)
25
+ .join(Candidate, MatchResult.candidate_id == Candidate.id)
26
+ .join(JobDescription, MatchResult.jd_id == JobDescription.id)
27
+ .where(MatchResult.id.in_(match_result_ids))
28
+ )
29
+ result = await db.execute(q)
30
+ rows = result.all()
31
+
32
+ success_count = 0
33
+ for mr, cand, jd in rows:
34
+ # Skip if already generated
35
+ if mr.explanation:
36
+ continue
37
+
38
+ jd_dict = _build_jd_dict(jd)
39
+ cand_dict = {
40
+ "parsed_summary": cand.parsed_summary,
41
+ "parsed_skills": cand.parsed_skills,
42
+ "years_of_experience": cand.years_of_experience,
43
+ "programming_languages": cand.programming_languages or [],
44
+ "backend_frameworks": cand.backend_frameworks or [],
45
+ "frontend_technologies": cand.frontend_technologies or [],
46
+ "most_recent_company": cand.most_recent_company,
47
+ "growth_velocity": cand.growth_velocity,
48
+ }
49
+
50
+ try:
51
+ # Dynamically utilize next Groq key in cycler and run the prompt
52
+ exp = await generate_explanation(jd_dict, cand_dict, mr.gaps or [])
53
+ mr.explanation = exp
54
+ mr.explanation_generated_at = datetime.datetime.now(datetime.timezone.utc)
55
+ await db.commit()
56
+ success_count += 1
57
+
58
+ # Sleep briefly 0.5s to absolutely guarantee Groq global rate limits remain pristine
59
+ await asyncio.sleep(0.5)
60
+ except Exception as e:
61
+ print(f"Failed to generate explanation for {mr.id}: {e}")
62
+
63
+ return {"attempted": len(rows), "succeeded": success_count}
frontend/src/app/jds/[id]/candidates/[cid]/page.tsx CHANGED
@@ -125,6 +125,9 @@ export default function CandidateDetailPage() {
125
  {(cand.backend_frameworks ?? []).map((l) => (
126
  <span key={l} className="text-xs px-2 py-0.5 rounded bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] text-[var(--color-brand-light)]">{l}</span>
127
  ))}
 
 
 
128
  </div>
129
  </div>
130
 
@@ -235,13 +238,17 @@ export default function CandidateDetailPage() {
235
  { label: "Looking for", val: cand.looking_for },
236
  { label: "Open to", val: cand.open_to_working_at },
237
  { label: "Status", val: cand.is_actively_or_passively_looking },
 
 
 
 
238
  { label: "Gen AI", val: cand.gen_ai_experience != null ? (cand.gen_ai_experience ? "✓ Yes" : "No") : undefined },
239
  { label: "Funded co", val: cand.most_recent_company_is_funded != null ? (cand.most_recent_company_is_funded ? "✓ Yes" : "No") : undefined },
240
  { label: "Product co", val: cand.most_recent_company_is_product_company != null ? (cand.most_recent_company_is_product_company ? "✓ Yes" : "No") : undefined },
241
- ] as { label: string; val?: string }[]).filter((r) => r.val != null).map(({ label, val }) => (
242
- <div key={label} className="flex justify-between gap-2 text-xs">
243
  <span className="text-[var(--color-dimmer)]">{label}</span>
244
- <span className="text-[var(--color-text)] font-medium text-right">{val}</span>
245
  </div>
246
  ))}
247
  </div>
 
125
  {(cand.backend_frameworks ?? []).map((l) => (
126
  <span key={l} className="text-xs px-2 py-0.5 rounded bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] text-[var(--color-brand-light)]">{l}</span>
127
  ))}
128
+ {(cand.frontend_technologies ?? []).map((l) => (
129
+ <span key={l} className="text-xs px-2 py-0.5 rounded bg-sky-500/10 border border-sky-500/20 text-sky-400">{l}</span>
130
+ ))}
131
  </div>
132
  </div>
133
 
 
238
  { label: "Looking for", val: cand.looking_for },
239
  { label: "Open to", val: cand.open_to_working_at },
240
  { label: "Status", val: cand.is_actively_or_passively_looking },
241
+ { label: "Notice period", val: cand.notice_period },
242
+ { label: "Tenure", val: cand.time_in_current_company != null ? `${cand.time_in_current_company} yrs` : undefined },
243
+ { label: "Education", val: cand.education_status },
244
+ { label: "Degree", val: cand.degree },
245
  { label: "Gen AI", val: cand.gen_ai_experience != null ? (cand.gen_ai_experience ? "✓ Yes" : "No") : undefined },
246
  { label: "Funded co", val: cand.most_recent_company_is_funded != null ? (cand.most_recent_company_is_funded ? "✓ Yes" : "No") : undefined },
247
  { label: "Product co", val: cand.most_recent_company_is_product_company != null ? (cand.most_recent_company_is_product_company ? "✓ Yes" : "No") : undefined },
248
+ ] as { label: string; val?: string | number }[]).filter((r) => r.val != null && String(r.val).trim() !== "").map(({ label, val }) => (
249
+ <div key={label} className="flex justify-between gap-2 text-xs border-b border-[var(--color-border-strong)]/30 pb-1.5 last:border-0 last:pb-0">
250
  <span className="text-[var(--color-dimmer)]">{label}</span>
251
+ <span className="text-[var(--color-text)] font-medium text-right max-w-[60%] line-clamp-2">{val}</span>
252
  </div>
253
  ))}
254
  </div>
frontend/src/app/jds/[id]/page.tsx CHANGED
@@ -4,7 +4,7 @@ import { useParams, useSearchParams } from "next/navigation";
4
  import Link from "next/link";
5
  import { api, type JD, type MatchResponse, type SessionInfo } from "../../../lib/api";
6
 
7
- const DEFAULT_WEIGHTS = { semantic: 0.20, skill: 0.35, yoe: 0.15, company: 0.10, growth: 0.10, education: 0.10 };
8
  const WEIGHT_LABELS: Record<string, string> = {
9
  semantic: "Semantic", skill: "Skill Match", yoe: "Experience",
10
  company: "Company", growth: "Growth", education: "Education",
@@ -21,6 +21,7 @@ export default function JDDetailPage() {
21
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
22
  const [selectedSession, setSelectedSession] = useState<string>(preselectedSession || "");
23
  const [match, setMatch] = useState<MatchResponse | null>(null);
 
24
  const [loading, setLoading] = useState(true);
25
  const [matching, setMatching] = useState(false);
26
  const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
@@ -30,12 +31,20 @@ export default function JDDetailPage() {
30
  const loadData = useCallback(async () => {
31
  const [jdData, sessionList] = await Promise.all([api.getJD(jdId), api.listSessions().catch(() => [])]);
32
  setJD(jdData);
 
 
 
 
 
33
  setSessions(sessionList as SessionInfo[]);
34
  if (preselectedSession || sessionList.length === 1) {
35
  const sid = preselectedSession || (sessionList as SessionInfo[])[0]?.id;
36
  if (sid) {
37
  setSelectedSession(sid);
38
- api.getMatchResults(jdId, sid).then(setMatch).catch(() => {});
 
 
 
39
  }
40
  }
41
  setLoading(false);
@@ -48,6 +57,7 @@ export default function JDDetailPage() {
48
  try {
49
  const r = await api.triggerMatch(jdId, selectedSession || undefined);
50
  setMatch(r);
 
51
  } catch (e: unknown) { alert((e as Error).message); }
52
  finally { setMatching(false); }
53
  };
@@ -55,13 +65,29 @@ export default function JDDetailPage() {
55
  const handleWeightChange = (key: string, val: number) => {
56
  const nw = { ...weights, [key]: val };
57
  setWeights(nw);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  if (debounceRef.current) clearTimeout(debounceRef.current);
59
- debounceRef.current = setTimeout(async () => {
60
- if (!match) return;
61
- setReranking(true);
62
- const r = await api.rerank(jdId, nw, selectedSession || undefined).catch(() => null);
63
- if (r) setMatch(r);
64
- setReranking(false);
65
  }, 400);
66
  };
67
 
@@ -226,7 +252,22 @@ export default function JDDetailPage() {
226
  ))}
227
  </div>
228
  <button className="mt-4 text-xs text-[var(--color-dimmer)] hover:text-[var(--color-muted)] w-full text-center transition-colors"
229
- onClick={() => { setWeights(DEFAULT_WEIGHTS); if (match) api.rerank(jdId, DEFAULT_WEIGHTS, selectedSession || undefined).then((r) => r && setMatch(r)); }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  ↺ Reset weights
231
  </button>
232
  </div>
 
4
  import Link from "next/link";
5
  import { api, type JD, type MatchResponse, type SessionInfo } from "../../../lib/api";
6
 
7
+ const DEFAULT_WEIGHTS = { semantic: 0.85, skill: 0.90, yoe: 0.95, company: 0.80, growth: 0.90, education: 0.75 };
8
  const WEIGHT_LABELS: Record<string, string> = {
9
  semantic: "Semantic", skill: "Skill Match", yoe: "Experience",
10
  company: "Company", growth: "Growth", education: "Education",
 
21
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
22
  const [selectedSession, setSelectedSession] = useState<string>(preselectedSession || "");
23
  const [match, setMatch] = useState<MatchResponse | null>(null);
24
+ const [baseResults, setBaseResults] = useState<MatchResponse["results"] | null>(null);
25
  const [loading, setLoading] = useState(true);
26
  const [matching, setMatching] = useState(false);
27
  const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
 
31
  const loadData = useCallback(async () => {
32
  const [jdData, sessionList] = await Promise.all([api.getJD(jdId), api.listSessions().catch(() => [])]);
33
  setJD(jdData);
34
+ if (jdData && jdData.custom_weights && Object.keys(jdData.custom_weights).length > 0) {
35
+ setWeights(jdData.custom_weights as typeof DEFAULT_WEIGHTS);
36
+ } else {
37
+ setWeights(DEFAULT_WEIGHTS);
38
+ }
39
  setSessions(sessionList as SessionInfo[]);
40
  if (preselectedSession || sessionList.length === 1) {
41
  const sid = preselectedSession || (sessionList as SessionInfo[])[0]?.id;
42
  if (sid) {
43
  setSelectedSession(sid);
44
+ api.getMatchResults(jdId, sid).then(r => {
45
+ setMatch(r);
46
+ setBaseResults(r.results);
47
+ }).catch(() => {});
48
  }
49
  }
50
  setLoading(false);
 
57
  try {
58
  const r = await api.triggerMatch(jdId, selectedSession || undefined);
59
  setMatch(r);
60
+ setBaseResults(r.results);
61
  } catch (e: unknown) { alert((e as Error).message); }
62
  finally { setMatching(false); }
63
  };
 
65
  const handleWeightChange = (key: string, val: number) => {
66
  const nw = { ...weights, [key]: val };
67
  setWeights(nw);
68
+
69
+ // Instance 0ms Array sorting natively!
70
+ if (baseResults && match) {
71
+ const totalW = Object.values(nw).reduce((a, b) => a + b, 0);
72
+ const wNorm = totalW > 0 ? Object.fromEntries(Object.entries(nw).map(([k, v]) => [k, v / totalW])) : nw;
73
+
74
+ const newRes = baseResults.map(c => {
75
+ let score = 0;
76
+ const cs = c.component_scores || {};
77
+ ["semantic", "skill", "yoe", "company", "growth", "education"].forEach(k => {
78
+ score += (cs[k] as number || 0) * (wNorm[k] || 0);
79
+ });
80
+ return { ...c, final_score: score };
81
+ });
82
+ newRes.sort((a, b) => b.final_score - a.final_score);
83
+ newRes.forEach((c, idx) => c.rank = idx + 1);
84
+ setMatch({ ...match, results: newRes });
85
+ }
86
+
87
  if (debounceRef.current) clearTimeout(debounceRef.current);
88
+ debounceRef.current = setTimeout(() => {
89
+ // Quietly save new config into DB silently
90
+ api.updateJDWeights(jdId, nw).catch(() => null);
 
 
 
91
  }, 400);
92
  };
93
 
 
252
  ))}
253
  </div>
254
  <button className="mt-4 text-xs text-[var(--color-dimmer)] hover:text-[var(--color-muted)] w-full text-center transition-colors"
255
+ onClick={() => {
256
+ setWeights(DEFAULT_WEIGHTS);
257
+ if (baseResults && match) {
258
+ const totalW = Object.values(DEFAULT_WEIGHTS).reduce((a, b) => a + b, 0);
259
+ const wNorm = totalW > 0 ? Object.fromEntries(Object.entries(DEFAULT_WEIGHTS).map(([k, v]) => [k, v / totalW])) : DEFAULT_WEIGHTS;
260
+ const newRes = baseResults.map(c => {
261
+ let score = 0; const cs = c.component_scores || {};
262
+ ["semantic", "skill", "yoe", "company", "growth", "education"].forEach(k => { score += (cs[k] as number || 0) * (wNorm[k] || 0); });
263
+ return { ...c, final_score: score };
264
+ });
265
+ newRes.sort((a, b) => b.final_score - a.final_score);
266
+ newRes.forEach((c, idx) => c.rank = idx + 1);
267
+ setMatch({ ...match, results: newRes });
268
+ }
269
+ api.updateJDWeights(jdId, DEFAULT_WEIGHTS).catch(() => null);
270
+ }}>
271
  ↺ Reset weights
272
  </button>
273
  </div>
frontend/src/app/page.tsx CHANGED
@@ -44,14 +44,9 @@ export default function HomePage() {
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
- <div className="flex items-center justify-center gap-4">
48
- <Link href="/sessions/new" className="px-6 py-3 rounded-xl bg-[var(--color-brand)] text-white font-semibold text-sm shadow-lg shadow-[var(--color-brand-glow)] hover:brightness-110 transition-all hover:-translate-y-0.5">
49
- + New Session
50
  </Link>
51
- <Link href="/jds/new" className="px-6 py-3 rounded-xl border border-[var(--color-border-strong)] text-[var(--color-text)] font-semibold text-sm hover:border-[var(--color-brand)] hover:bg-[var(--color-card)] transition-all">
52
- + Post a JD
53
- </Link>
54
- </div>
55
  </div>
56
 
57
  <div className="grid grid-cols-3 gap-5 mb-12">
 
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">
frontend/src/app/pipeline/page.tsx ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useRef } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { api } from "../../lib/api";
6
+
7
+ interface PipelineState {
8
+ status: "idle" | "uploading" | "embedding" | "matching" | "complete";
9
+ sessionName: string;
10
+ jdTitle: string;
11
+ jdId?: string;
12
+ sessionId?: string;
13
+ taskId?: string;
14
+ startTime?: number;
15
+ elapsedTime: number;
16
+ }
17
+
18
+ const DEFAULT_STATE: PipelineState = { status: "idle", sessionName: "", jdTitle: "", elapsedTime: 0 };
19
+
20
+ export default function PipelinePage() {
21
+ const router = useRouter();
22
+
23
+ // Pipeline definition
24
+ const steps = [
25
+ { id: "idle", label: "Configure Run", icon: "📝" },
26
+ { id: "uploading", label: "Ingesting Data", icon: "📤" },
27
+ { id: "embedding", label: "Vector Embedding", icon: "🧠" },
28
+ { id: "matching", label: "Neural Reranking", icon: "⚡" },
29
+ { id: "complete", label: "Finalized", icon: "🎯" }
30
+ ];
31
+
32
+ // Inputs
33
+ const [sessionName, setSessionName] = useState("");
34
+ const [jdTitle, setJdTitle] = useState("");
35
+ const [jdDesc, setJdDesc] = useState("");
36
+ const [file, setFile] = useState<File | null>(null);
37
+
38
+ // Architecture state
39
+ const [state, setState] = useState<PipelineState>(DEFAULT_STATE);
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
43
+
44
+ useEffect(() => {
45
+ // Load state from local storage on mount
46
+ const saved = localStorage.getItem("talentpulse_pipeline");
47
+ if (saved) {
48
+ try {
49
+ const p = JSON.parse(saved) as PipelineState;
50
+ if (p.status !== "idle" && p.status !== "complete") {
51
+ setState(p);
52
+ // Resume pipeline loop
53
+ if (p.status === "embedding" && p.taskId) pollEmbedding(p.taskId, p);
54
+ if (p.status === "matching" && p.jdId && p.sessionId) runMatch(p.jdId, p.sessionId, p);
55
+ }
56
+ } catch (e) {}
57
+ }
58
+ }, []);
59
+
60
+ useEffect(() => {
61
+ if (state.status !== "idle" && state.status !== "complete") {
62
+ localStorage.setItem("talentpulse_pipeline", JSON.stringify(state));
63
+ if (!timerRef.current && state.startTime) {
64
+ timerRef.current = setInterval(() => {
65
+ setState(s => ({ ...s, elapsedTime: Math.floor((Date.now() - (s.startTime || Date.now())) / 1000) }));
66
+ }, 1000);
67
+ }
68
+ } else {
69
+ if (timerRef.current) {
70
+ clearInterval(timerRef.current);
71
+ timerRef.current = null;
72
+ }
73
+ if (state.status === "complete") {
74
+ localStorage.removeItem("talentpulse_pipeline");
75
+ }
76
+ }
77
+ return () => { if (timerRef.current) clearInterval(timerRef.current); };
78
+ }, [state.status, state.startTime]);
79
+
80
+ const updateState = (update: Partial<PipelineState>) => {
81
+ setState(s => {
82
+ const ns = { ...s, ...update };
83
+ if (ns.status !== "idle" && ns.status !== "complete") localStorage.setItem("talentpulse_pipeline", JSON.stringify(ns));
84
+ return ns;
85
+ });
86
+ };
87
+
88
+ const startPipeline = async () => {
89
+ if (!sessionName || !jdTitle || !jdDesc || !file) {
90
+ setError("Please fill out all fields and select a file.");
91
+ return;
92
+ }
93
+ setError(null);
94
+ const start = Date.now();
95
+ updateState({ status: "uploading", sessionName, jdTitle, startTime: start, elapsedTime: 0 });
96
+
97
+ try {
98
+ // 1. Create Session & JD parallel
99
+ const [session, jd] = await Promise.all([
100
+ api.createSession(sessionName, "Generated by Auto-Pipeline"),
101
+ api.createJD(jdTitle, jdDesc)
102
+ ]);
103
+
104
+ updateState({ sessionId: session.id, jdId: jd.id });
105
+
106
+ // 2. Upload file
107
+ const uploadRes = await api.uploadCandidates(file, session.id);
108
+
109
+ updateState({ status: "embedding", taskId: uploadRes.task_id });
110
+
111
+ // 3. Poll embedding
112
+ pollEmbedding(uploadRes.task_id, { ...state, status: "embedding", sessionId: session.id, jdId: jd.id, startTime: start });
113
+
114
+ } catch (e: any) {
115
+ setError("Pipeline failed: " + e.message);
116
+ updateState({ status: "idle" });
117
+ }
118
+ };
119
+
120
+ const pollEmbedding = async (taskId: string, currentState: PipelineState) => {
121
+ const poll = setInterval(async () => {
122
+ try {
123
+ const s = await api.taskStatus(taskId);
124
+ if (s.status === "SUCCESS") {
125
+ clearInterval(poll);
126
+ updateState({ status: "matching" });
127
+ runMatch(currentState.jdId!, currentState.sessionId!, currentState);
128
+ } else if (s.status === "FAILURE") {
129
+ clearInterval(poll);
130
+ setError("Vector embedding failed.");
131
+ updateState({ status: "idle" });
132
+ }
133
+ } catch (e) {
134
+ // network error during polling, keep trying
135
+ }
136
+ }, 3000);
137
+ };
138
+
139
+ const runMatch = async (jdId: string, sessionId: string, currentState: PipelineState) => {
140
+ try {
141
+ await api.triggerMatch(jdId, sessionId);
142
+ updateState({ status: "complete" });
143
+ } catch (e: any) {
144
+ setError("Matching failed: " + e.message);
145
+ updateState({ status: "idle" });
146
+ }
147
+ };
148
+
149
+ const formatTime = (sec: number) => {
150
+ const m = Math.floor(sec / 60);
151
+ const s = sec % 60;
152
+ return `${m}:${s.toString().padStart(2, "0")}`;
153
+ };
154
+
155
+ const currentStepIdx = steps.findIndex(s => s.id === state.status);
156
+
157
+ return (
158
+ <div className="max-w-4xl mx-auto px-6 py-12">
159
+ <div className="text-center mb-10">
160
+ <h1 className="text-3xl font-extrabold tracking-tight mb-3">Universal Run Pipeline</h1>
161
+ <p className="text-[var(--color-muted)] text-sm">One-way sequential ingestion, mapping, and reranking hook.</p>
162
+ </div>
163
+
164
+ {/* STEPPER UI */}
165
+ <div className="mb-12 relative">
166
+ <div className="absolute top-6 left-[10%] right-[10%] h-0.5 bg-[var(--color-border-strong)] -z-10" />
167
+ <div className="absolute top-6 left-[10%] h-0.5 bg-[var(--color-brand)] -z-10 transition-all duration-700"
168
+ style={{ width: `${Math.max(0, (currentStepIdx / (steps.length - 1)) * 80)}%` }} />
169
+
170
+ <div className="flex justify-between relative z-10">
171
+ {steps.map((step, idx) => {
172
+ const isActive = state.status === step.id;
173
+ const isPast = idx < currentStepIdx;
174
+ return (
175
+ <div key={step.id} className="flex flex-col items-center w-24">
176
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center text-xl mb-3 border-2 transition-all duration-500
177
+ ${isActive ? 'bg-[var(--color-brand-dim)] border-[var(--color-brand-light)] text-white shadow-[0_0_20px_var(--color-brand-dim)]'
178
+ : isPast ? 'bg-[var(--color-brand)] border-[var(--color-brand)] text-white'
179
+ : 'bg-[var(--color-surface-2)] border-[var(--color-border-strong)] text-[var(--color-muted)] opacity-50' }`}
180
+ >
181
+ {step.icon}
182
+ </div>
183
+ <div className={`text-xs font-medium text-center ${isActive ? 'text-[var(--color-brand-light)]' : isPast ? 'text-[var(--color-text)]' : 'text-[var(--color-muted)] opacity-50'}`}>
184
+ {step.label}
185
+ </div>
186
+ </div>
187
+ );
188
+ })}
189
+ </div>
190
+ </div>
191
+
192
+ {/* TIMER */}
193
+ {state.status !== "idle" && (
194
+ <div className="flex justify-center mb-8">
195
+ <div className="inline-flex items-center gap-3 px-6 py-3 rounded-2xl bg-[var(--color-surface-2)] border border-[var(--color-border-strong)]">
196
+ <div className="w-2 h-2 rounded-full bg-[var(--color-brand-light)] animate-pulse" />
197
+ <div className="text-sm font-medium text-[var(--color-muted)]">Time Elapsed:</div>
198
+ <div className="font-mono text-xl font-bold tracking-wider">{formatTime(state.elapsedTime)}</div>
199
+ </div>
200
+ </div>
201
+ )}
202
+
203
+ {error && (
204
+ <div className="bg-red-500/10 border border-red-500/20 text-red-500 text-sm px-4 py-3 rounded-xl mb-6 text-center">
205
+ {error}
206
+ </div>
207
+ )}
208
+
209
+ {state.status === "idle" ? (
210
+ <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-8 shadow-xl shadow-black/5">
211
+ <div className="grid grid-cols-2 gap-6 mb-6">
212
+ <div>
213
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Session Name</label>
214
+ <input type="text" placeholder="e.g. Q3 Engineering Batch (100k)"
215
+ className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl px-4 py-3 text-sm outline-none focus:border-[var(--color-brand)] transition-all"
216
+ value={sessionName} onChange={e => setSessionName(e.target.value)} />
217
+ </div>
218
+ <div>
219
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Job Title</label>
220
+ <input type="text" placeholder="e.g. Senior Backend Engineer"
221
+ className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl px-4 py-3 text-sm outline-none focus:border-[var(--color-brand)] transition-all"
222
+ value={jdTitle} onChange={e => setJdTitle(e.target.value)} />
223
+ </div>
224
+ </div>
225
+
226
+ <div className="mb-6">
227
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Job Description</label>
228
+ <textarea placeholder="Paste the full job description here..." rows={4}
229
+ className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border-strong)] rounded-xl px-4 py-3 text-sm outline-none focus:border-[var(--color-brand)] transition-all resize-none"
230
+ value={jdDesc} onChange={e => setJdDesc(e.target.value)} />
231
+ </div>
232
+
233
+ <div className="mb-8">
234
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-2">Candidates CSV (.csv, .json)</label>
235
+ <input type="file" accept=".csv,.json,.jsonl"
236
+ 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"
237
+ onChange={e => setFile(e.target.files?.[0] || null)} />
238
+ </div>
239
+
240
+ <button onClick={startPipeline}
241
+ 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]">
242
+ START AUTOMATED PIPELINE
243
+ </button>
244
+ </div>
245
+ ) : state.status === "complete" ? (
246
+ <div className="text-center bg-[var(--color-card)] border border-[var(--color-border)] rounded-2xl p-10 shadow-xl shadow-black/5 animate-fade-in">
247
+ <div className="text-6xl mb-4">🎉</div>
248
+ <h2 className="text-2xl font-bold mb-2">Automated Inference Complete!</h2>
249
+ <p className="text-[var(--color-muted)] mb-8 max-w-sm mx-auto">
250
+ 100% of candidate logic calculated safely. The background worker is aggressively pulling LLM explanations for the top 60 right now.
251
+ </p>
252
+ <button onClick={() => router.push(`/jds/${state.jdId}?session_id=${state.sessionId}`)}
253
+ className="px-8 py-3 rounded-xl bg-[var(--color-brand)] text-white font-semibold hover:brightness-110 transition-all shadow-lg shadow-[var(--color-brand-glow)]">
254
+ View Ranked Candidates →
255
+ </button>
256
+ </div>
257
+ ) : (
258
+ <div className="text-center bg-[var(--color-card)] border border-dashed border-[var(--color-border-strong)] rounded-2xl p-16 animate-fade-in">
259
+ <div className="w-16 h-16 border-4 border-[var(--color-brand-dim)] border-t-[var(--color-brand-light)] rounded-full animate-spin mx-auto mb-6" />
260
+ <h2 className="text-xl font-semibold mb-2">
261
+ {state.status === "uploading" ? "Broadcasting to Postgres DB..."
262
+ : state.status === "embedding" ? "Running Core CPU Vector Space Projection..."
263
+ : "Executing Dual-Stage Neural Match..."}
264
+ </h2>
265
+ <p className="text-[var(--color-dimmer)] text-sm">
266
+ Do not close this tab. The timer will automatically pause and redirect upon completion.
267
+ </p>
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
frontend/src/app/sessions/[id]/page.tsx CHANGED
@@ -114,6 +114,21 @@ export default function SessionDetailPage() {
114
  </div>
115
  </div>
116
  <div className="flex gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  <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">
118
  {uploading ? <><div className="w-3 h-3 border border-[var(--color-brand)] border-t-transparent rounded-full animate-spin" /> Uploading…</> : "📤 Add Candidates"}
119
  <input type="file" accept=".csv,.json,.jsonl" className="hidden" onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])} />
 
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])} />
frontend/src/lib/api.ts CHANGED
@@ -33,6 +33,7 @@ export interface JD {
33
  location: string | null;
34
  required_skills: string[];
35
  jd_quality: JDQuality;
 
36
  created_at: string;
37
  }
38
 
@@ -112,11 +113,14 @@ export const api = {
112
  request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify({ name, description }) }),
113
  listSessions: () => request<SessionInfo[]>("/api/sessions"),
114
  getSession: (id: string) => request<SessionInfo>(`/api/sessions/${id}`),
 
115
 
116
  createJD: (title: string, raw_text: string) =>
117
  request<JD>("/api/jds", { method: "POST", body: JSON.stringify({ title, raw_text }) }),
118
  listJDs: () => request<JD[]>("/api/jds"),
119
  getJD: (id: string) => request<JD>(`/api/jds/${id}`),
 
 
120
 
121
  uploadCandidates: (file: File, sessionId?: string) => {
122
  const fd = new FormData();
 
33
  location: string | null;
34
  required_skills: string[];
35
  jd_quality: JDQuality;
36
+ custom_weights: Record<string, number>;
37
  created_at: string;
38
  }
39
 
 
113
  request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify({ name, description }) }),
114
  listSessions: () => request<SessionInfo[]>("/api/sessions"),
115
  getSession: (id: string) => request<SessionInfo>(`/api/sessions/${id}`),
116
+ deleteSession: (id: string) => request<void>(`/api/sessions/${id}`, { method: "DELETE" }),
117
 
118
  createJD: (title: string, raw_text: string) =>
119
  request<JD>("/api/jds", { method: "POST", body: JSON.stringify({ title, raw_text }) }),
120
  listJDs: () => request<JD[]>("/api/jds"),
121
  getJD: (id: string) => request<JD>(`/api/jds/${id}`),
122
+ updateJDWeights: (id: string, weights: Record<string, number>) =>
123
+ request<JD>(`/api/jds/${id}/weights`, { method: "PATCH", body: JSON.stringify({ weights }) }),
124
 
125
  uploadCandidates: (file: File, sessionId?: string) => {
126
  const fd = new FormData();
implementation_plan.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architectural Evolution: Linear Monolithic Pipeline & Frontend Inference
2
+
3
+ Thank you for the strict scale constraints. 100,000 candidates is a massive load for a localized environment, but our architecture can handle it easily with the strategies outlined below. Let's break down your questions about execution times first, and then the structural redesign!
4
+
5
+ ## ⏱ Scale & Time Estimations (100,000 Candidates)
6
+ 1. **NeonDB Free Tier (Postgres Storage):** 100,000 candidates with basic text descriptions will consume roughly **40MB to 60MB** of space. Neon's free tier provides 500MB, therefore you will have exactly zero problems running this! After the test, clicking the "Delete Session" button will cleanly wipe the 50MB so you never run out of space.
7
+ 2. **Database Postgres Insertion Time:** Because your backend is using SQLAlchemy asynchronous insertion through Celery chunks, it will take approximately **2 to 3 minutes** to map 100k rows to NeonDB.
8
+ 3. **Vector Database Embedding Time (BAAI Model):** **This is your bottleneck.** Because you are running a Deep Learning model on a CPU, vectorizing 100k profiles usually runs at ~30 documents/second/core. On a standard 8-core laptop, this will take approximately **40 to 50 minutes** to finish background embedding.
9
+ - **How to minimize this?** The optimal minimization strategy is deploying the application to a machine with an NVIDIA GPU (T4 or L4). Embedding times would drop from 45 minutes to **~2 minutes**. Alternatively, I can add a flag to maximize Celery Concurrency up to your CPU's exact core limit to squeeze every drop of local performance simultaneously.
10
+ 4. **Matching Time (Top 250):** The Qdrant neural search combined with the cross-encoder Reranker will operate extremely fast, taking around **8 to 15 seconds** total to sift through the 100k space and yield the top 250.
11
+
12
+ Here is exactly how I will redesign the system to meet your "One-Way Pipeline" constraints:
13
+
14
+ ## Proposed Changes
15
+
16
+ ### 1. The Monolithic Pipeline (Frontend Orchestrator)
17
+ Currently, you have to click around to create sessions, upload data, create a JD, and click "Run Match". **I will destroy this fragmentation.**
18
+ #### [NEW] `frontend/src/app/pipeline/page.tsx`
19
+ - I will create a single "Run Pipeline" page mapping exactly to your vision.
20
+ - **Form:** You enter Job Title, Job Description text, and upload the 100k CSV.
21
+ - **Action:** Clicking "Start" initiates an intelligent, localized script:
22
+ 1. Creates the JD API asynchronously.
23
+ 2. Creates a Session automatically.
24
+ 3. Uploads the CSV batch to Celery.
25
+ 4. Automatically polls the embedding status.
26
+ 5. The moment embeddings finish, it automatically executes the Match function.
27
+ 6. Automatically redirects you to the definitive Top 250 visualization page, with the background Top 60 LLM thread running invisibly!
28
+
29
+ ### 2. Zero-Latency Frontend Weight Logic
30
+ You correctly identified that network trips during weight-slider tweaks are unnecessary since we already pipe the data to the browser!
31
+ #### [MODIFY] `frontend/src/app/jds/[id]/page.tsx`
32
+ - I will rip out the `POST /api/rerank` network call that runs whenever you touch a slider.
33
+ - I will write Javascript math logic that instantly calculates your normalized weight distribution directly inside the browser using the raw `component_scores`, sorting the DOM elements exactly into their new ranks at **0ms latency**.
34
+ - To satisfy your requirement *"after sometime if user visit that so user can have whatever beta I have set for that weight"*, the frontend will quietly ping a new tiny `PATCH` endpoint simply to save the slider coordinates in Postgres behind the scenes, never waiting for it!
35
+
36
+ ### 3. Dedicated Backend Configurations
37
+ #### [NEW] `backend/src/routers/jds.py` (Update)
38
+ - I will add a tiny `PATCH /api/jds/{jd_id}/weights` route so the frontend can silently save the state of your sliders.
39
+ #### [MODIFY] `backend/src/workers/celery_app.py`
40
+ - I will explicitly configure Celery concurrency settings to forcefully max-out your laptop's multi-threading to guarantee the 100k embedding finishes as fast as theoretically possible on local hardware.
41
+
42
+ ## User Review Required
43
+
44
+ > [!WARNING]
45
+ > By shifting to the Monolithic Pipeline model, the current dashboard will just point to the "Run Universal Pipeline" flow instead of manual modular steps. Are you completely comfortable moving exactly to this one-way system?
46
+
47
+ > [!TIP]
48
+ > If you approve, I will strip away the fragmented pages and implement the unified flow with maximum multi-threading parameters!