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