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 +151 -0
- backend/src/config.py +14 -0
- backend/src/database.py +5 -0
- backend/src/matching/llm_explainer.py +2 -2
- backend/src/models/jd.py +1 -0
- backend/src/routers/jds.py +17 -0
- backend/src/routers/matching.py +20 -3
- backend/src/routers/sessions.py +29 -0
- backend/src/schemas/jd.py +1 -0
- backend/src/workers/celery_app.py +1 -1
- backend/src/workers/explain.py +63 -0
- frontend/src/app/jds/[id]/candidates/[cid]/page.tsx +10 -3
- frontend/src/app/jds/[id]/page.tsx +50 -9
- frontend/src/app/page.tsx +2 -7
- frontend/src/app/pipeline/page.tsx +272 -0
- frontend/src/app/sessions/[id]/page.tsx +15 -0
- frontend/src/lib/api.ts +4 -0
- implementation_plan.md +48 -0
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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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.
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 60 |
-
|
| 61 |
-
|
| 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={() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 48 |
-
|
| 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!
|