Spaces:
Sleeping
Sleeping
team99tech commited on
Commit ·
dfb3d07
1
Parent(s): 7e3263e
Added changes
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +53 -0
- README.md +99 -1
- backend/agents/__pycache__/challenger.cpython-312.pyc +0 -0
- backend/agents/__pycache__/extractor.cpython-312.pyc +0 -0
- backend/agents/__pycache__/interviewer.cpython-312.pyc +0 -0
- backend/agents/__pycache__/scorer.cpython-312.pyc +0 -0
- backend/agents/challenger.py +59 -0
- backend/agents/extractor.py +76 -0
- backend/agents/interviewer.py +55 -0
- backend/agents/scorer.py +62 -0
- backend/data/sample_jd_resume.json +4 -0
- backend/graph_pipeline/__pycache__/edges.cpython-312.pyc +0 -0
- backend/graph_pipeline/__pycache__/nodes.cpython-312.pyc +0 -0
- backend/graph_pipeline/__pycache__/pipeline.cpython-312.pyc +0 -0
- backend/graph_pipeline/__pycache__/state.cpython-312.pyc +0 -0
- backend/graph_pipeline/edges.py +28 -0
- backend/graph_pipeline/nodes.py +176 -0
- backend/graph_pipeline/pipeline.py +50 -0
- backend/graph_pipeline/state.py +30 -0
- backend/knowledge_graph/__pycache__/graph_engine.cpython-312.pyc +0 -0
- backend/knowledge_graph/graph_engine.py +120 -0
- backend/knowledge_graph/resources.json +62 -0
- backend/knowledge_graph/skills_graph.json +517 -0
- backend/main.py +27 -0
- backend/models/__pycache__/schemas.cpython-312.pyc +0 -0
- backend/models/schemas.py +91 -0
- backend/output/__pycache__/roadmap_generator.cpython-312.pyc +0 -0
- backend/output/roadmap_generator.py +89 -0
- backend/requirements.txt +12 -0
- backend/routers/__pycache__/assessment.cpython-312.pyc +0 -0
- backend/routers/__pycache__/roadmap.cpython-312.pyc +0 -0
- backend/routers/__pycache__/upload.cpython-312.pyc +0 -0
- backend/routers/assessment.py +99 -0
- backend/routers/roadmap.py +34 -0
- backend/routers/upload.py +24 -0
- backend/scoring/__pycache__/claim_vs_reality.cpython-312.pyc +0 -0
- backend/scoring/__pycache__/gap_classifier.cpython-312.pyc +0 -0
- backend/scoring/claim_vs_reality.py +26 -0
- backend/scoring/gap_classifier.py +7 -0
- frontend/app/assess/[id]/page.tsx +99 -0
- frontend/app/candidate/job/[id]/page.tsx +129 -0
- frontend/app/candidate/page.tsx +83 -0
- frontend/app/employer/job/[id]/page.tsx +118 -0
- frontend/app/employer/page.tsx +136 -0
- frontend/app/globals.css +22 -0
- frontend/app/layout.tsx +22 -0
- frontend/app/login/page.tsx +151 -0
- frontend/app/page.tsx +43 -0
- frontend/app/results/[id]/page.tsx +195 -0
- frontend/components/assessment/ChatInterface.tsx +142 -0
.gitignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# General OS & Environment
|
| 2 |
+
.DS_Store
|
| 3 |
+
Thumbs.db
|
| 4 |
+
*.log
|
| 5 |
+
.env
|
| 6 |
+
.env.local
|
| 7 |
+
.env.development.local
|
| 8 |
+
.env.test.local
|
| 9 |
+
.env.production.local
|
| 10 |
+
!.env.example
|
| 11 |
+
!.env.local.example
|
| 12 |
+
|
| 13 |
+
COPILOT_PROMPT.md
|
| 14 |
+
|
| 15 |
+
# Frontend (Node.js & Next.js)
|
| 16 |
+
frontend/node_modules/
|
| 17 |
+
frontend/.pnp
|
| 18 |
+
frontend/.pnp.js
|
| 19 |
+
frontend/.next/
|
| 20 |
+
frontend/out/
|
| 21 |
+
frontend/build/
|
| 22 |
+
frontend/coverage/
|
| 23 |
+
frontend/.vercel
|
| 24 |
+
|
| 25 |
+
# Backend (Python)
|
| 26 |
+
backend/__pycache__/
|
| 27 |
+
backend/*.py[cod]
|
| 28 |
+
backend/*$py.class
|
| 29 |
+
backend/.pytest_cache/
|
| 30 |
+
backend/.coverage
|
| 31 |
+
backend/htmlcov/
|
| 32 |
+
backend/venv/
|
| 33 |
+
backend/env/
|
| 34 |
+
backend/ENV/
|
| 35 |
+
backend/env.bak/
|
| 36 |
+
backend/venv.bak/
|
| 37 |
+
backend/.venv/
|
| 38 |
+
backend/build/
|
| 39 |
+
backend/develop-eggs/
|
| 40 |
+
backend/dist/
|
| 41 |
+
backend/downloads/
|
| 42 |
+
backend/eggs/
|
| 43 |
+
backend/.eggs/
|
| 44 |
+
backend/lib/
|
| 45 |
+
backend/lib64/
|
| 46 |
+
backend/parts/
|
| 47 |
+
backend/sdist/
|
| 48 |
+
backend/var/
|
| 49 |
+
backend/wheels/
|
| 50 |
+
backend/share/python-wheels/
|
| 51 |
+
backend/*.egg-info/
|
| 52 |
+
backend/.installed.cfg
|
| 53 |
+
backend/*.egg
|
README.md
CHANGED
|
@@ -1 +1,99 @@
|
|
| 1 |
-
# SkillForge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SkillForge AI
|
| 2 |
+
|
| 3 |
+
> **From resume claims to real capability.**
|
| 4 |
+
|
| 5 |
+
A next-generation AI agent that takes a Job Description and a candidate's resume, conversationally assesses real proficiency on each required skill, identifies gaps, and generates a personalised learning plan focused on adjacent skills the candidate can realistically acquire — with curated resources and time estimates.
|
| 6 |
+
|
| 7 |
+
No simple keyword matching. No generic RAG. Pure **Knowledge Graph traversal** + **multi-agent real-time conversation**.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## ⚡ Architecture & Tech Stack
|
| 12 |
+
|
| 13 |
+
SkillForge AI is built as a full-stack monorepo featuring a powerful Python/AI backend and a blazing fast Next.js frontend.
|
| 14 |
+
|
| 15 |
+
### Backend (AI & Logic)
|
| 16 |
+
- **Framework:** FastAPI
|
| 17 |
+
- **Agent Orchestration:** LangGraph (Multi-agent state machines)
|
| 18 |
+
- **LLM Engine:** LangChain + Groq (`llama-3.3-70b-versatile`) for lightning-fast inference
|
| 19 |
+
- **Knowledge Graph:** NetworkX (Directed graphs with custom edge weights)
|
| 20 |
+
- **PDF Processing:** `pdfplumber` for clean JD/Resume extraction
|
| 21 |
+
|
| 22 |
+
### Frontend (User Interface)
|
| 23 |
+
- **Framework:** Next.js 14 (App Router)
|
| 24 |
+
- **Styling:** Tailwind CSS + Custom Industrial Dark Theme
|
| 25 |
+
- **State Management:** Zustand
|
| 26 |
+
- **Streaming:** Server-Sent Events (SSE) via native Web API `EventSource`
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## 🧠 How it Works
|
| 31 |
+
|
| 32 |
+
1. **Extraction (`extractor.py`):** An LLM extracts structured capabilities from the candidate's resume and the target job description.
|
| 33 |
+
2. **Gap Analysis (`claim_vs_reality.py`):** Cross-references resume claims against job requirements to flag high-priority gaps.
|
| 34 |
+
3. **Conversational Assessment (`interviewer.py` & `scorer.py`):** The agent engages the candidate in a dynamic, progressive Q&A to verify actual competency rather than just stated experience.
|
| 35 |
+
4. **Targeted Challenges (`challenger.py`):** For intermediate/senior skills, the agent generates grounded "find-the-bug" style micro-challenges.
|
| 36 |
+
5. **Roadmap Generation (`roadmap_generator.py`):** The Knowledge Graph engine (`graph_engine.py`) calculates the shortest learning path from what the candidate *already knows* to what they *need to know*, packaging it into a multi-tiered weekly syllabus.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 🚀 Running Locally
|
| 41 |
+
|
| 42 |
+
### 1. Start the FastAPI Backend
|
| 43 |
+
You will need a [Groq API Key](https://console.groq.com/keys) to run the LLM models.
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
cd backend
|
| 47 |
+
python -m venv venv
|
| 48 |
+
|
| 49 |
+
# Windows
|
| 50 |
+
.\venv\Scripts\Activate.ps1
|
| 51 |
+
# Mac/Linux
|
| 52 |
+
source venv/bin/activate
|
| 53 |
+
|
| 54 |
+
pip install -r requirements.txt
|
| 55 |
+
|
| 56 |
+
# Set up your environment variables
|
| 57 |
+
cp .env.example .env
|
| 58 |
+
# Edit .env and add your GROQ_API_KEY
|
| 59 |
+
|
| 60 |
+
uvicorn main:app --reload --port 8000
|
| 61 |
+
```
|
| 62 |
+
*The backend API will be running at `http://localhost:8000`*
|
| 63 |
+
|
| 64 |
+
### 2. Start the Next.js Frontend
|
| 65 |
+
In a new terminal window:
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
cd frontend
|
| 69 |
+
npm install
|
| 70 |
+
|
| 71 |
+
# Ensure your local environment is pointing to the backend
|
| 72 |
+
cp .env.local.example .env.local
|
| 73 |
+
|
| 74 |
+
npm run dev
|
| 75 |
+
```
|
| 76 |
+
*The frontend will be running at `http://localhost:3000`*
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## 📂 Repository Structure
|
| 81 |
+
|
| 82 |
+
```text
|
| 83 |
+
skillforge-ai/
|
| 84 |
+
├── backend/ # Python FastAPI Backend
|
| 85 |
+
│ ├── agents/ # LangChain/Groq agent definitions
|
| 86 |
+
│ ├── data/ # Sample JDs and Resumes
|
| 87 |
+
│ ├── graph_pipeline/ # LangGraph state machine & nodes
|
| 88 |
+
│ ├── knowledge_graph/ # NetworkX implementation & static graph JSON
|
| 89 |
+
│ ├── models/ # Pydantic data schemas
|
| 90 |
+
│ ├── output/ # Roadmap synthesis logic
|
| 91 |
+
│ ├── routers/ # FastAPI endpoints (Upload, Assess, Roadmap)
|
| 92 |
+
│ └── scoring/ # Algorithmic mismatch and gap scoring
|
| 93 |
+
│
|
| 94 |
+
└── frontend/ # Next.js 14 Frontend
|
| 95 |
+
├── app/ # Next.js App Router pages
|
| 96 |
+
├── components/ # React components (Upload, Assessment, Results)
|
| 97 |
+
├── hooks/ # Custom React hooks (e.g. SSE streaming)
|
| 98 |
+
└── lib/ # API clients, Zustand store, and TS Types
|
| 99 |
+
```
|
backend/agents/__pycache__/challenger.cpython-312.pyc
ADDED
|
Binary file (2.86 kB). View file
|
|
|
backend/agents/__pycache__/extractor.cpython-312.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
backend/agents/__pycache__/interviewer.cpython-312.pyc
ADDED
|
Binary file (2.61 kB). View file
|
|
|
backend/agents/__pycache__/scorer.cpython-312.pyc
ADDED
|
Binary file (3.16 kB). View file
|
|
|
backend/agents/challenger.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 3 |
+
import json, os
|
| 4 |
+
|
| 5 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 6 |
+
|
| 7 |
+
CHALLENGER_SYSTEM = """You are generating a technical debugging challenge.
|
| 8 |
+
The challenge must:
|
| 9 |
+
1. Be directly relevant to the target skill
|
| 10 |
+
2. Contain exactly ONE bug or design flaw
|
| 11 |
+
3. Be solvable in 3–5 minutes by someone with genuine intermediate knowledge
|
| 12 |
+
4. Include a brief context sentence before the code/scenario
|
| 13 |
+
5. Use markdown with a fenced code block if code is involved
|
| 14 |
+
|
| 15 |
+
Return ONLY valid JSON (no markdown fences):
|
| 16 |
+
{
|
| 17 |
+
"challenge_type": "find_the_bug",
|
| 18 |
+
"skill_id": "docker",
|
| 19 |
+
"context": "Your teammate pushed this Dockerfile. The container starts but immediately exits.",
|
| 20 |
+
"task": "```dockerfile\\nFROM python:3.11\\nWORKDIR /app\\nCOPY . .\\nRUN pip install -r requirements.txt\\nCMD uvicorn main:app --host 0.0.0.0 --port 8000\\n```",
|
| 21 |
+
"expected_answer": "CMD uses shell form — uvicorn won't receive SIGTERM. Fix: CMD [\\"uvicorn\\", \\"main:app\\", \\"--host\\", \\"0.0.0.0\\", \\"--port\\", \\"8000\\"]",
|
| 22 |
+
"difficulty": "intermediate"
|
| 23 |
+
}"""
|
| 24 |
+
|
| 25 |
+
def get_llm() -> ChatGroq:
|
| 26 |
+
return ChatGroq(
|
| 27 |
+
model=GROQ_MODEL,
|
| 28 |
+
api_key=os.getenv("GROQ_API_KEY", "dummy"),
|
| 29 |
+
temperature=0.3,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
def generate_challenge(
|
| 33 |
+
skill_id: str,
|
| 34 |
+
graph_path: list[str],
|
| 35 |
+
resume_context: str,
|
| 36 |
+
) -> dict:
|
| 37 |
+
llm = get_llm()
|
| 38 |
+
prompt = f"Skill: {skill_id}\nGraph Path: {graph_path}\nResume context: {resume_context}"
|
| 39 |
+
messages = [
|
| 40 |
+
SystemMessage(content=CHALLENGER_SYSTEM),
|
| 41 |
+
HumanMessage(content=prompt)
|
| 42 |
+
]
|
| 43 |
+
response = llm.invoke(messages)
|
| 44 |
+
try:
|
| 45 |
+
content = response.content.strip()
|
| 46 |
+
if content.startswith("```json"):
|
| 47 |
+
content = content[7:]
|
| 48 |
+
if content.endswith("```"):
|
| 49 |
+
content = content[:-3]
|
| 50 |
+
return json.loads(content)
|
| 51 |
+
except Exception:
|
| 52 |
+
return {
|
| 53 |
+
"challenge_type": "find_the_bug",
|
| 54 |
+
"skill_id": skill_id,
|
| 55 |
+
"context": "Explain a common bug in this context.",
|
| 56 |
+
"task": "No task generated",
|
| 57 |
+
"expected_answer": "Any reasonable explanation",
|
| 58 |
+
"difficulty": "intermediate"
|
| 59 |
+
}
|
backend/agents/extractor.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 3 |
+
import json, os
|
| 4 |
+
from models.schemas import ExtractionResult
|
| 5 |
+
|
| 6 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 7 |
+
|
| 8 |
+
EXTRACTION_SYSTEM = """You are a skill extraction engine for a technical interview platform.
|
| 9 |
+
Extract structured information from a job description and a candidate resume.
|
| 10 |
+
|
| 11 |
+
Return ONLY valid JSON matching this exact schema — no markdown fences, no explanation:
|
| 12 |
+
{
|
| 13 |
+
"jd_skills": [
|
| 14 |
+
{
|
| 15 |
+
"skill_id": "python",
|
| 16 |
+
"label": "Python",
|
| 17 |
+
"priority": "high",
|
| 18 |
+
"years_required": 3,
|
| 19 |
+
"context": "3+ years Python required"
|
| 20 |
+
}
|
| 21 |
+
],
|
| 22 |
+
"resume_skills": [
|
| 23 |
+
{
|
| 24 |
+
"skill_id": "python",
|
| 25 |
+
"label": "Python",
|
| 26 |
+
"evidence_strength": 0.7,
|
| 27 |
+
"years_mentioned": 2,
|
| 28 |
+
"context": "2 years Python scripting"
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
"seniority_level": "mid",
|
| 32 |
+
"domain": "backend"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
skill_id rules: lowercase, underscores, no spaces. "GitHub Actions" → "github_actions"
|
| 36 |
+
evidence_strength rubric:
|
| 37 |
+
0.0 = not mentioned
|
| 38 |
+
0.4 = mentioned in passing
|
| 39 |
+
0.7 = mentioned with project/years
|
| 40 |
+
1.0 = specific metrics or production outcomes
|
| 41 |
+
priority: "high" if required/must-have, "medium" if preferred, "low" if nice-to-have
|
| 42 |
+
domain: one of backend | data_engineering | ml | devops"""
|
| 43 |
+
|
| 44 |
+
def get_llm() -> ChatGroq:
|
| 45 |
+
return ChatGroq(
|
| 46 |
+
model=GROQ_MODEL,
|
| 47 |
+
api_key=os.getenv("GROQ_API_KEY", "dummy"),
|
| 48 |
+
temperature=0.1,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def extract_skills(jd_text: str, resume_text: str) -> ExtractionResult:
|
| 52 |
+
llm = get_llm()
|
| 53 |
+
messages = [
|
| 54 |
+
SystemMessage(content=EXTRACTION_SYSTEM),
|
| 55 |
+
HumanMessage(content=f"JD: {jd_text}\n\nResume: {resume_text}")
|
| 56 |
+
]
|
| 57 |
+
response = llm.invoke(messages)
|
| 58 |
+
try:
|
| 59 |
+
content = response.content.strip()
|
| 60 |
+
if content.startswith("```json"):
|
| 61 |
+
content = content[7:]
|
| 62 |
+
if content.endswith("```"):
|
| 63 |
+
content = content[:-3]
|
| 64 |
+
data = json.loads(content)
|
| 65 |
+
return ExtractionResult.model_validate(data)
|
| 66 |
+
except Exception:
|
| 67 |
+
return ExtractionResult(
|
| 68 |
+
jd_skills=[],
|
| 69 |
+
resume_skills=[],
|
| 70 |
+
seniority_level="junior",
|
| 71 |
+
domain="backend"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def normalize_skill_id(label: str) -> str:
|
| 75 |
+
import re
|
| 76 |
+
return re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
|
backend/agents/interviewer.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 6 |
+
|
| 7 |
+
INTERVIEWER_SYSTEM = """You are a senior technical interviewer.
|
| 8 |
+
Generate ONE question for the given skill and difficulty level.
|
| 9 |
+
|
| 10 |
+
Rules:
|
| 11 |
+
- If difficulty is 'conceptual' or 'scenario', MUST generate a Multiple Choice Question (with A, B, C, D options).
|
| 12 |
+
- If difficulty is 'applied' or 'debugging', MUST generate an open-ended descriptive question (NO options).
|
| 13 |
+
- For MCQs, clearly list the options but DO NOT include the correct answer in your output.
|
| 14 |
+
- Reference the candidate's resume context if available
|
| 15 |
+
- Return ONLY the question text (and choices if MCQ) — no numbering, no preamble, and NO answer keys."""
|
| 16 |
+
|
| 17 |
+
difficulty_ladder = {
|
| 18 |
+
1: "conceptual",
|
| 19 |
+
2: "applied",
|
| 20 |
+
3: "scenario",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
def get_llm() -> ChatGroq:
|
| 24 |
+
return ChatGroq(
|
| 25 |
+
model=GROQ_MODEL,
|
| 26 |
+
api_key=os.getenv("GROQ_API_KEY", "dummy"),
|
| 27 |
+
temperature=0.7,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
def get_difficulty(question_number: int, current_score: float) -> str:
|
| 31 |
+
if question_number == 1:
|
| 32 |
+
return "conceptual"
|
| 33 |
+
if question_number == 2:
|
| 34 |
+
return "applied" if current_score > 0.5 else "conceptual"
|
| 35 |
+
if question_number >= 3:
|
| 36 |
+
return "scenario" if current_score > 0.6 else "applied"
|
| 37 |
+
return "conceptual"
|
| 38 |
+
|
| 39 |
+
def generate_question(
|
| 40 |
+
skill_label: str,
|
| 41 |
+
resume_context: str,
|
| 42 |
+
jd_requirement_level: str,
|
| 43 |
+
current_score: float,
|
| 44 |
+
question_number: int,
|
| 45 |
+
previous_questions: list[str],
|
| 46 |
+
) -> str:
|
| 47 |
+
difficulty = get_difficulty(question_number, current_score)
|
| 48 |
+
llm = get_llm()
|
| 49 |
+
prompt = f"Skill: {skill_label}\nDifficulty: {difficulty}\nResume Context: {resume_context}\nPrevious Questions: {previous_questions}"
|
| 50 |
+
messages = [
|
| 51 |
+
SystemMessage(content=INTERVIEWER_SYSTEM),
|
| 52 |
+
HumanMessage(content=prompt)
|
| 53 |
+
]
|
| 54 |
+
response = llm.invoke(messages)
|
| 55 |
+
return response.content.strip()
|
backend/agents/scorer.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 3 |
+
import json, os
|
| 4 |
+
|
| 5 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 6 |
+
|
| 7 |
+
SCORER_SYSTEM = """You are a silent technical interview rubric evaluator.
|
| 8 |
+
Score the candidate answer on a 0.0–1.0 scale.
|
| 9 |
+
|
| 10 |
+
Rubric:
|
| 11 |
+
0.0–0.2 = No knowledge. Vague or wrong.
|
| 12 |
+
0.3–0.4 = Surface awareness only. No applied depth.
|
| 13 |
+
0.5–0.6 = Working knowledge. Correct but generic.
|
| 14 |
+
0.7–0.8 = Applied knowledge. Specific examples or tradeoffs.
|
| 15 |
+
0.9–1.0 = Expert. Edge cases, alternatives, nuanced reasoning.
|
| 16 |
+
|
| 17 |
+
Note: If the question was a Multiple Choice Question (MCQ), evaluate if their chosen option is correct. Give 0.8–1.0 for the correct choice (1.0 if they also briefly explain why), and 0.0 for an incorrect choice.
|
| 18 |
+
|
| 19 |
+
Return ONLY valid JSON (no markdown):
|
| 20 |
+
{
|
| 21 |
+
"score": 0.72,
|
| 22 |
+
"signal": "move_on",
|
| 23 |
+
"reasoning": "Candidate described specific tradeoffs in FastAPI dependency injection."
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
signal rules:
|
| 27 |
+
"probe_deeper" → (score < 0.5 OR questions_asked == 1) AND questions_asked < 3
|
| 28 |
+
"fire_challenge" → score < 0.5 AND questions_asked >= 2
|
| 29 |
+
"move_on" → (score >= 0.5 AND questions_asked >= 2) OR questions_asked >= 3"""
|
| 30 |
+
|
| 31 |
+
def get_llm() -> ChatGroq:
|
| 32 |
+
return ChatGroq(
|
| 33 |
+
model=GROQ_MODEL,
|
| 34 |
+
api_key=os.getenv("GROQ_API_KEY", "dummy"),
|
| 35 |
+
temperature=0.0,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def score_answer(
|
| 39 |
+
skill: str,
|
| 40 |
+
question: str,
|
| 41 |
+
answer: str,
|
| 42 |
+
questions_asked: int,
|
| 43 |
+
) -> dict:
|
| 44 |
+
llm = get_llm()
|
| 45 |
+
prompt = f"Skill: {skill}\nQuestion: {question}\nAnswer: {answer}\nQuestions asked: {questions_asked}"
|
| 46 |
+
messages = [
|
| 47 |
+
SystemMessage(content=SCORER_SYSTEM),
|
| 48 |
+
HumanMessage(content=prompt)
|
| 49 |
+
]
|
| 50 |
+
response = llm.invoke(messages)
|
| 51 |
+
try:
|
| 52 |
+
content = response.content.strip()
|
| 53 |
+
if content.startswith("```json"):
|
| 54 |
+
content = content[7:]
|
| 55 |
+
if content.endswith("```"):
|
| 56 |
+
content = content[:-3]
|
| 57 |
+
return json.loads(content)
|
| 58 |
+
except Exception:
|
| 59 |
+
return {"score": 0.0, "signal": "move_on", "reasoning": "Failed to parse response."}
|
| 60 |
+
|
| 61 |
+
def compute_final_score(resume_evidence: float, conversation_score: float) -> float:
|
| 62 |
+
return round(0.35 * resume_evidence + 0.65 * conversation_score, 3)
|
backend/data/sample_jd_resume.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"jd": "Mid-Level Data Engineer. Requirements: 3-5 years of experience in data engineering. Strong SQL skills. Experience with Snowflake and dbt for data transformations. Proficiency in Python for scripting and automation. Familiarity with Airflow for orchestrating data pipelines. Bachelor's degree in Computer Science or related field.",
|
| 3 |
+
"resume": "Junior Data Engineer passionate about data. I have 1 year of experience working with Python and SQL to build basic reports. I've taken a few courses on Pandas and have touched Airflow in a personal project. Looking for an opportunity to grow."
|
| 4 |
+
}
|
backend/graph_pipeline/__pycache__/edges.cpython-312.pyc
ADDED
|
Binary file (1.26 kB). View file
|
|
|
backend/graph_pipeline/__pycache__/nodes.cpython-312.pyc
ADDED
|
Binary file (8.56 kB). View file
|
|
|
backend/graph_pipeline/__pycache__/pipeline.cpython-312.pyc
ADDED
|
Binary file (1.83 kB). View file
|
|
|
backend/graph_pipeline/__pycache__/state.cpython-312.pyc
ADDED
|
Binary file (1.48 kB). View file
|
|
|
backend/graph_pipeline/edges.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from graph_pipeline.state import AssessmentState
|
| 2 |
+
|
| 3 |
+
def route_after_scorer(state: AssessmentState) -> str:
|
| 4 |
+
records = state.get("question_records", [])
|
| 5 |
+
if not records:
|
| 6 |
+
return "output_node"
|
| 7 |
+
|
| 8 |
+
last_record = records[-1]
|
| 9 |
+
signal = last_record.signal
|
| 10 |
+
|
| 11 |
+
if signal == "probe_deeper":
|
| 12 |
+
return "interviewer_node"
|
| 13 |
+
elif signal == "fire_challenge":
|
| 14 |
+
return "challenger_node"
|
| 15 |
+
else:
|
| 16 |
+
idx = state.get("current_skill_index", 0)
|
| 17 |
+
gap_skills = state.get("gap_skill_ids", [])
|
| 18 |
+
if idx < len(gap_skills):
|
| 19 |
+
return "interviewer_node"
|
| 20 |
+
else:
|
| 21 |
+
return "output_node"
|
| 22 |
+
|
| 23 |
+
def route_after_challenger(state: AssessmentState) -> str:
|
| 24 |
+
idx = state.get("current_skill_index", 0)
|
| 25 |
+
gap_skills = state.get("gap_skill_ids", [])
|
| 26 |
+
if idx < len(gap_skills):
|
| 27 |
+
return "interviewer_node"
|
| 28 |
+
return "output_node"
|
backend/graph_pipeline/nodes.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from graph_pipeline.state import AssessmentState
|
| 2 |
+
from agents.extractor import extract_skills
|
| 3 |
+
from agents.interviewer import generate_question, get_difficulty
|
| 4 |
+
from agents.scorer import score_answer, compute_final_score
|
| 5 |
+
from agents.challenger import generate_challenge
|
| 6 |
+
from scoring.claim_vs_reality import detect_mismatch
|
| 7 |
+
from scoring.gap_classifier import classify_gap
|
| 8 |
+
from output.roadmap_generator import generate_roadmap
|
| 9 |
+
from knowledge_graph.graph_engine import get_engine
|
| 10 |
+
from models.schemas import QuestionRecord, SkillScore, ChallengeTask
|
| 11 |
+
|
| 12 |
+
def extract_node(state: AssessmentState) -> dict:
|
| 13 |
+
jd_text = state["jd_text"]
|
| 14 |
+
resume_text = state["resume_text"]
|
| 15 |
+
extraction = extract_skills(jd_text, resume_text)
|
| 16 |
+
|
| 17 |
+
gap_skill_ids = []
|
| 18 |
+
resume_skills_dict = {s.skill_id: s for s in extraction.resume_skills}
|
| 19 |
+
|
| 20 |
+
for jd_skill in extraction.jd_skills:
|
| 21 |
+
rs = resume_skills_dict.get(jd_skill.skill_id)
|
| 22 |
+
if not rs or rs.evidence_strength < 0.6:
|
| 23 |
+
gap_skill_ids.append(jd_skill.skill_id)
|
| 24 |
+
|
| 25 |
+
engine = get_engine()
|
| 26 |
+
graph_paths = {}
|
| 27 |
+
known_skills = [s.skill_id for s in extraction.resume_skills if s.evidence_strength >= 0.6]
|
| 28 |
+
if not known_skills:
|
| 29 |
+
known_skills = ["python"] # fallback
|
| 30 |
+
|
| 31 |
+
for gap_skill in gap_skill_ids:
|
| 32 |
+
path = engine.find_shortest_path(known_skills, gap_skill)
|
| 33 |
+
if path:
|
| 34 |
+
graph_paths[gap_skill] = path
|
| 35 |
+
else:
|
| 36 |
+
graph_paths[gap_skill] = [gap_skill]
|
| 37 |
+
|
| 38 |
+
return {
|
| 39 |
+
"extraction": extraction,
|
| 40 |
+
"gap_skill_ids": gap_skill_ids,
|
| 41 |
+
"graph_paths": graph_paths,
|
| 42 |
+
"stream_events": [{"event": "extraction_complete", "data": extraction.model_dump()}]
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def interviewer_node(state: AssessmentState) -> dict:
|
| 46 |
+
idx = state.get("current_skill_index", 0)
|
| 47 |
+
gap_skills = state.get("gap_skill_ids", [])
|
| 48 |
+
if idx >= len(gap_skills):
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
skill_id = gap_skills[idx]
|
| 52 |
+
extraction = state["extraction"]
|
| 53 |
+
|
| 54 |
+
jd_skill = next((s for s in extraction.jd_skills if s.skill_id == skill_id), None)
|
| 55 |
+
rs_skill = next((s for s in extraction.resume_skills if s.skill_id == skill_id), None)
|
| 56 |
+
|
| 57 |
+
resume_context = rs_skill.context if rs_skill else "No resume context"
|
| 58 |
+
jd_req_level = jd_skill.priority if jd_skill else "medium"
|
| 59 |
+
|
| 60 |
+
questions_asked = state.get("questions_asked", 0)
|
| 61 |
+
current_score = state.get("conversation_scores", {}).get(skill_id, 0.5)
|
| 62 |
+
|
| 63 |
+
previous_questions = [q.question for q in state.get("question_records", []) if q.skill_id == skill_id]
|
| 64 |
+
|
| 65 |
+
skill_label = jd_skill.label if jd_skill else skill_id
|
| 66 |
+
question = generate_question(skill_label, resume_context, jd_req_level, current_score, questions_asked + 1, previous_questions)
|
| 67 |
+
|
| 68 |
+
qr = QuestionRecord(skill_id=skill_id, question=question, answer="", score=0.0, signal="move_on")
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
"stream_events": [{"event": "question", "skill_id": skill_id, "content": question}],
|
| 72 |
+
"questions_asked": questions_asked + 1,
|
| 73 |
+
"question_records": [qr]
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
def scorer_node(state: AssessmentState) -> dict:
|
| 77 |
+
records = state.get("question_records", [])
|
| 78 |
+
if not records:
|
| 79 |
+
return {}
|
| 80 |
+
|
| 81 |
+
last_record = records[-1]
|
| 82 |
+
pending_answer = state.get("pending_answer", "")
|
| 83 |
+
questions_asked = state.get("questions_asked", 0)
|
| 84 |
+
|
| 85 |
+
score_result = score_answer(last_record.skill_id, last_record.question, pending_answer, questions_asked)
|
| 86 |
+
|
| 87 |
+
# Python lists are mutable, we just mutate the record in place
|
| 88 |
+
last_record.answer = pending_answer
|
| 89 |
+
last_record.score = score_result.get("score", 0.0)
|
| 90 |
+
last_record.signal = score_result.get("signal", "move_on")
|
| 91 |
+
|
| 92 |
+
conversation_scores = state.get("conversation_scores", {})
|
| 93 |
+
skill_id = last_record.skill_id
|
| 94 |
+
|
| 95 |
+
skill_records = [r for r in records if r.skill_id == skill_id and r.answer]
|
| 96 |
+
avg_score = sum(r.score for r in skill_records) / len(skill_records) if skill_records else 0.0
|
| 97 |
+
|
| 98 |
+
conversation_scores[skill_id] = avg_score
|
| 99 |
+
|
| 100 |
+
update = {
|
| 101 |
+
"conversation_scores": conversation_scores,
|
| 102 |
+
"pending_answer": None,
|
| 103 |
+
"stream_events": []
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if last_record.signal == "move_on":
|
| 107 |
+
idx = state.get("current_skill_index", 0)
|
| 108 |
+
update["current_skill_index"] = idx + 1
|
| 109 |
+
update["questions_asked"] = 0
|
| 110 |
+
|
| 111 |
+
return update
|
| 112 |
+
|
| 113 |
+
def challenger_node(state: AssessmentState) -> dict:
|
| 114 |
+
idx = state.get("current_skill_index", 0)
|
| 115 |
+
gap_skills = state.get("gap_skill_ids", [])
|
| 116 |
+
if idx >= len(gap_skills):
|
| 117 |
+
return {}
|
| 118 |
+
skill_id = gap_skills[idx]
|
| 119 |
+
|
| 120 |
+
graph_path = state.get("graph_paths", {}).get(skill_id, [])
|
| 121 |
+
extraction = state["extraction"]
|
| 122 |
+
rs_skill = next((s for s in extraction.resume_skills if s.skill_id == skill_id), None)
|
| 123 |
+
resume_context = rs_skill.context if rs_skill else "No resume context"
|
| 124 |
+
|
| 125 |
+
challenge = generate_challenge(skill_id, graph_path, resume_context)
|
| 126 |
+
ct = ChallengeTask(**challenge)
|
| 127 |
+
|
| 128 |
+
markdown_task = challenge.get("task", "")
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"challenge_task": ct,
|
| 132 |
+
"stream_events": [{"event": "challenge", "skill_id": skill_id, "content": markdown_task}],
|
| 133 |
+
"current_skill_index": idx + 1,
|
| 134 |
+
"questions_asked": 0
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
def output_node(state: AssessmentState) -> dict:
|
| 138 |
+
extraction = state["extraction"]
|
| 139 |
+
conversation_scores = state.get("conversation_scores", {})
|
| 140 |
+
|
| 141 |
+
resume_skills_dict = {s.skill_id: s for s in extraction.resume_skills}
|
| 142 |
+
skill_scores = []
|
| 143 |
+
|
| 144 |
+
for jd_skill in extraction.jd_skills:
|
| 145 |
+
skill_id = jd_skill.skill_id
|
| 146 |
+
rs = resume_skills_dict.get(skill_id)
|
| 147 |
+
resume_evidence = rs.evidence_strength if rs else 0.0
|
| 148 |
+
|
| 149 |
+
conv_score = conversation_scores.get(skill_id, resume_evidence)
|
| 150 |
+
final_score = compute_final_score(resume_evidence, conv_score)
|
| 151 |
+
|
| 152 |
+
mismatch_data = detect_mismatch(skill_id, resume_evidence, conv_score)
|
| 153 |
+
gap_level = classify_gap(final_score)
|
| 154 |
+
|
| 155 |
+
skill_scores.append(SkillScore(
|
| 156 |
+
skill_id=skill_id,
|
| 157 |
+
label=jd_skill.label,
|
| 158 |
+
resume_evidence=resume_evidence,
|
| 159 |
+
conversation_score=conv_score,
|
| 160 |
+
final_score=final_score,
|
| 161 |
+
gap_level=gap_level,
|
| 162 |
+
mismatch=mismatch_data["mismatch"],
|
| 163 |
+
mismatch_severity=mismatch_data["severity"]
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
graph_paths = state.get("graph_paths", {})
|
| 167 |
+
hours_per_day = state.get("hours_per_day", 2.0)
|
| 168 |
+
|
| 169 |
+
roadmap = generate_roadmap(skill_scores, graph_paths, hours_per_day)
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"skill_scores": skill_scores,
|
| 173 |
+
"roadmap": roadmap,
|
| 174 |
+
"assessment_complete": True,
|
| 175 |
+
"stream_events": [{"event": "assessment_complete", "data": {"status": "done"}}]
|
| 176 |
+
}
|
backend/graph_pipeline/pipeline.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, END
|
| 2 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 3 |
+
from graph_pipeline.state import AssessmentState
|
| 4 |
+
from graph_pipeline.nodes import (
|
| 5 |
+
extract_node, interviewer_node, scorer_node,
|
| 6 |
+
challenger_node, output_node
|
| 7 |
+
)
|
| 8 |
+
from graph_pipeline.edges import route_after_scorer, route_after_challenger
|
| 9 |
+
|
| 10 |
+
def build_pipeline():
|
| 11 |
+
builder = StateGraph(AssessmentState)
|
| 12 |
+
|
| 13 |
+
builder.add_node("extract_node", extract_node)
|
| 14 |
+
builder.add_node("interviewer_node", interviewer_node)
|
| 15 |
+
builder.add_node("scorer_node", scorer_node)
|
| 16 |
+
builder.add_node("challenger_node", challenger_node)
|
| 17 |
+
builder.add_node("output_node", output_node)
|
| 18 |
+
|
| 19 |
+
builder.add_edge("extract_node", "interviewer_node")
|
| 20 |
+
builder.add_edge("interviewer_node", "scorer_node")
|
| 21 |
+
|
| 22 |
+
builder.add_conditional_edges(
|
| 23 |
+
"scorer_node",
|
| 24 |
+
route_after_scorer,
|
| 25 |
+
{
|
| 26 |
+
"interviewer_node": "interviewer_node",
|
| 27 |
+
"challenger_node": "challenger_node",
|
| 28 |
+
"output_node": "output_node"
|
| 29 |
+
}
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
builder.add_conditional_edges(
|
| 33 |
+
"challenger_node",
|
| 34 |
+
route_after_challenger,
|
| 35 |
+
{
|
| 36 |
+
"interviewer_node": "interviewer_node",
|
| 37 |
+
"output_node": "output_node"
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
builder.add_edge("output_node", END)
|
| 42 |
+
builder.set_entry_point("extract_node")
|
| 43 |
+
|
| 44 |
+
memory = MemorySaver()
|
| 45 |
+
return builder.compile(
|
| 46 |
+
checkpointer=memory,
|
| 47 |
+
interrupt_before=["scorer_node"],
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
pipeline = build_pipeline()
|
backend/graph_pipeline/state.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict, Annotated
|
| 2 |
+
import operator
|
| 3 |
+
from models.schemas import (
|
| 4 |
+
ExtractionResult, QuestionRecord, ChallengeTask, SkillScore, RoadmapWeek
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
class AssessmentState(TypedDict):
|
| 8 |
+
assessment_id: str
|
| 9 |
+
jd_text: str
|
| 10 |
+
resume_text: str
|
| 11 |
+
hours_per_day: float
|
| 12 |
+
|
| 13 |
+
extraction: ExtractionResult | None
|
| 14 |
+
gap_skill_ids: list[str]
|
| 15 |
+
graph_paths: dict[str, list[str]]
|
| 16 |
+
|
| 17 |
+
current_skill_index: int
|
| 18 |
+
questions_asked: int
|
| 19 |
+
questions_per_skill: int
|
| 20 |
+
|
| 21 |
+
question_records: Annotated[list[QuestionRecord], operator.add]
|
| 22 |
+
conversation_scores: dict[str, float]
|
| 23 |
+
challenge_task: ChallengeTask | None
|
| 24 |
+
pending_answer: str | None
|
| 25 |
+
|
| 26 |
+
stream_events: Annotated[list[dict], operator.add]
|
| 27 |
+
|
| 28 |
+
skill_scores: list[SkillScore]
|
| 29 |
+
roadmap: list[RoadmapWeek]
|
| 30 |
+
assessment_complete: bool
|
backend/knowledge_graph/__pycache__/graph_engine.cpython-312.pyc
ADDED
|
Binary file (7.3 kB). View file
|
|
|
backend/knowledge_graph/graph_engine.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import networkx as nx
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Dict, Optional
|
| 6 |
+
|
| 7 |
+
class SkillGraphEngine:
|
| 8 |
+
def __init__(self, graph_path: str = "backend/knowledge_graph/skills_graph.json"):
|
| 9 |
+
self.graph = nx.DiGraph()
|
| 10 |
+
path = Path(graph_path)
|
| 11 |
+
if path.exists():
|
| 12 |
+
with open(path, 'r', encoding='utf-8') as f:
|
| 13 |
+
data = json.load(f)
|
| 14 |
+
for node in data.get("nodes", []):
|
| 15 |
+
self.graph.add_node(
|
| 16 |
+
node["id"],
|
| 17 |
+
label=node.get("label", node["id"]),
|
| 18 |
+
domain=node.get("domain", "unknown"),
|
| 19 |
+
level=node.get("level", "intermediate"),
|
| 20 |
+
tags=node.get("tags", [])
|
| 21 |
+
)
|
| 22 |
+
for edge in data.get("edges", []):
|
| 23 |
+
self.graph.add_edge(
|
| 24 |
+
edge["source"],
|
| 25 |
+
edge["target"],
|
| 26 |
+
type=edge.get("type", "PREREQUISITE"),
|
| 27 |
+
weight=edge.get("weight", 1.0),
|
| 28 |
+
hop_cost=edge.get("hop_cost", 1)
|
| 29 |
+
)
|
| 30 |
+
if edge.get("type") == "COUSIN":
|
| 31 |
+
# Bidirectional
|
| 32 |
+
self.graph.add_edge(
|
| 33 |
+
edge["target"],
|
| 34 |
+
edge["source"],
|
| 35 |
+
type=edge.get("type", "COUSIN"),
|
| 36 |
+
weight=edge.get("weight", 1.0),
|
| 37 |
+
hop_cost=edge.get("hop_cost", 1)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def find_shortest_path(self, source_skills: List[str], target_skill: str, max_hops: int = 4) -> Optional[List[str]]:
|
| 41 |
+
best_path = None
|
| 42 |
+
best_cost = float('inf')
|
| 43 |
+
|
| 44 |
+
if not self.graph.has_node(target_skill):
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
for source in source_skills:
|
| 48 |
+
if not self.graph.has_node(source):
|
| 49 |
+
continue
|
| 50 |
+
try:
|
| 51 |
+
path = nx.shortest_path(self.graph, source=source, target=target_skill, weight="hop_cost")
|
| 52 |
+
if len(path) - 1 <= max_hops:
|
| 53 |
+
cost = sum(self.graph[path[i]][path[i+1]]['hop_cost'] for i in range(len(path)-1))
|
| 54 |
+
if cost < best_cost:
|
| 55 |
+
best_cost = cost
|
| 56 |
+
best_path = path
|
| 57 |
+
except nx.NetworkXNoPath:
|
| 58 |
+
continue
|
| 59 |
+
return best_path
|
| 60 |
+
|
| 61 |
+
def get_adjacent(self, skill_id: str, hops: int = 2) -> List[Dict]:
|
| 62 |
+
if not self.graph.has_node(skill_id):
|
| 63 |
+
return []
|
| 64 |
+
lengths = nx.single_source_dijkstra_path_length(self.graph, skill_id, cutoff=hops, weight="hop_cost")
|
| 65 |
+
paths = nx.single_source_dijkstra_path(self.graph, skill_id, cutoff=hops, weight="hop_cost")
|
| 66 |
+
|
| 67 |
+
results = []
|
| 68 |
+
for target, cost in lengths.items():
|
| 69 |
+
if target == skill_id:
|
| 70 |
+
continue
|
| 71 |
+
path = paths[target]
|
| 72 |
+
edge_types = [self.graph[path[i]][path[i+1]]['type'] for i in range(len(path)-1)]
|
| 73 |
+
results.append({
|
| 74 |
+
"skill": target,
|
| 75 |
+
"path": path,
|
| 76 |
+
"edge_types": edge_types,
|
| 77 |
+
"total_cost": float(cost)
|
| 78 |
+
})
|
| 79 |
+
results.sort(key=lambda x: x["total_cost"])
|
| 80 |
+
return results
|
| 81 |
+
|
| 82 |
+
def get_prerequisites(self, skill_id: str) -> List[str]:
|
| 83 |
+
if not self.graph.has_node(skill_id):
|
| 84 |
+
return []
|
| 85 |
+
return [p for p in self.graph.predecessors(skill_id) if self.graph[p][skill_id]['type'] == 'PREREQUISITE']
|
| 86 |
+
|
| 87 |
+
def get_domain(self, skill_id: str) -> str:
|
| 88 |
+
if not self.graph.has_node(skill_id):
|
| 89 |
+
return "unknown"
|
| 90 |
+
return self.graph.nodes[skill_id].get("domain", "unknown")
|
| 91 |
+
|
| 92 |
+
def path_to_steps(self, path: List[str]) -> List[Dict]:
|
| 93 |
+
steps = []
|
| 94 |
+
for i in range(len(path) - 1):
|
| 95 |
+
source = path[i]
|
| 96 |
+
target = path[i+1]
|
| 97 |
+
edge_type = self.graph[source][target]['type']
|
| 98 |
+
weeks = 1.5
|
| 99 |
+
if edge_type == 'COUSIN':
|
| 100 |
+
weeks = 0.75
|
| 101 |
+
elif edge_type == 'BRIDGES':
|
| 102 |
+
weeks = 2.5
|
| 103 |
+
steps.append({
|
| 104 |
+
"from": source,
|
| 105 |
+
"to": target,
|
| 106 |
+
"edge_type": edge_type,
|
| 107 |
+
"weeks": weeks
|
| 108 |
+
})
|
| 109 |
+
return steps
|
| 110 |
+
|
| 111 |
+
def normalize_skill_id(self, label: str) -> str:
|
| 112 |
+
return re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
|
| 113 |
+
|
| 114 |
+
_engine = None
|
| 115 |
+
|
| 116 |
+
def get_engine() -> SkillGraphEngine:
|
| 117 |
+
global _engine
|
| 118 |
+
if _engine is None:
|
| 119 |
+
_engine = SkillGraphEngine()
|
| 120 |
+
return _engine
|
backend/knowledge_graph/resources.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fastapi": {
|
| 3 |
+
"courses": [
|
| 4 |
+
{
|
| 5 |
+
"title": "FastAPI - The Complete Course",
|
| 6 |
+
"url": "https://www.udemy.com/course/fastapi-the-complete-course/",
|
| 7 |
+
"hours": 12,
|
| 8 |
+
"type": "course"
|
| 9 |
+
}
|
| 10 |
+
]
|
| 11 |
+
},
|
| 12 |
+
"docker": {
|
| 13 |
+
"courses": [
|
| 14 |
+
{
|
| 15 |
+
"title": "Docker Mastery: with Kubernetes +Swarm from a Docker Captain",
|
| 16 |
+
"url": "https://www.udemy.com/course/docker-mastery/",
|
| 17 |
+
"hours": 20,
|
| 18 |
+
"type": "course"
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
"dbt": {
|
| 23 |
+
"courses": [
|
| 24 |
+
{
|
| 25 |
+
"title": "Data Engineering with dbt",
|
| 26 |
+
"url": "https://www.datacamp.com/courses/introduction-to-dbt",
|
| 27 |
+
"hours": 8,
|
| 28 |
+
"type": "course"
|
| 29 |
+
}
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
"airflow": {
|
| 33 |
+
"courses": [
|
| 34 |
+
{
|
| 35 |
+
"title": "Apache Airflow: The Hands-On Guide",
|
| 36 |
+
"url": "https://www.udemy.com/course/the-complete-hands-on-course-to-master-apache-airflow/",
|
| 37 |
+
"hours": 10,
|
| 38 |
+
"type": "course"
|
| 39 |
+
}
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
"pytorch": {
|
| 43 |
+
"courses": [
|
| 44 |
+
{
|
| 45 |
+
"title": "PyTorch for Deep Learning in 2024: Zero to Mastery",
|
| 46 |
+
"url": "https://www.udemy.com/course/pytorch-for-deep-learning/",
|
| 47 |
+
"hours": 25,
|
| 48 |
+
"type": "course"
|
| 49 |
+
}
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
"kubernetes": {
|
| 53 |
+
"courses": [
|
| 54 |
+
{
|
| 55 |
+
"title": "Kubernetes for the Absolute Beginners - Hands-on",
|
| 56 |
+
"url": "https://www.udemy.com/course/learn-kubernetes/",
|
| 57 |
+
"hours": 15,
|
| 58 |
+
"type": "course"
|
| 59 |
+
}
|
| 60 |
+
]
|
| 61 |
+
}
|
| 62 |
+
}
|
backend/knowledge_graph/skills_graph.json
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nodes": [
|
| 3 |
+
{
|
| 4 |
+
"id": "python",
|
| 5 |
+
"label": "Python",
|
| 6 |
+
"domain": "backend",
|
| 7 |
+
"level": "intermediate",
|
| 8 |
+
"tags": []
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"id": "fastapi",
|
| 12 |
+
"label": "Fastapi",
|
| 13 |
+
"domain": "backend",
|
| 14 |
+
"level": "intermediate",
|
| 15 |
+
"tags": []
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"id": "flask",
|
| 19 |
+
"label": "Flask",
|
| 20 |
+
"domain": "backend",
|
| 21 |
+
"level": "intermediate",
|
| 22 |
+
"tags": []
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"id": "django",
|
| 26 |
+
"label": "Django",
|
| 27 |
+
"domain": "backend",
|
| 28 |
+
"level": "intermediate",
|
| 29 |
+
"tags": []
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"id": "nodejs",
|
| 33 |
+
"label": "Nodejs",
|
| 34 |
+
"domain": "backend",
|
| 35 |
+
"level": "intermediate",
|
| 36 |
+
"tags": []
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "expressjs",
|
| 40 |
+
"label": "Expressjs",
|
| 41 |
+
"domain": "backend",
|
| 42 |
+
"level": "intermediate",
|
| 43 |
+
"tags": []
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"id": "rest_api",
|
| 47 |
+
"label": "Rest Api",
|
| 48 |
+
"domain": "backend",
|
| 49 |
+
"level": "intermediate",
|
| 50 |
+
"tags": []
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "graphql",
|
| 54 |
+
"label": "Graphql",
|
| 55 |
+
"domain": "backend",
|
| 56 |
+
"level": "intermediate",
|
| 57 |
+
"tags": []
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "grpc",
|
| 61 |
+
"label": "Grpc",
|
| 62 |
+
"domain": "backend",
|
| 63 |
+
"level": "intermediate",
|
| 64 |
+
"tags": []
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"id": "websockets",
|
| 68 |
+
"label": "Websockets",
|
| 69 |
+
"domain": "backend",
|
| 70 |
+
"level": "intermediate",
|
| 71 |
+
"tags": []
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"id": "celery",
|
| 75 |
+
"label": "Celery",
|
| 76 |
+
"domain": "backend",
|
| 77 |
+
"level": "intermediate",
|
| 78 |
+
"tags": []
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": "redis",
|
| 82 |
+
"label": "Redis",
|
| 83 |
+
"domain": "backend",
|
| 84 |
+
"level": "intermediate",
|
| 85 |
+
"tags": []
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "postgresql",
|
| 89 |
+
"label": "Postgresql",
|
| 90 |
+
"domain": "backend",
|
| 91 |
+
"level": "intermediate",
|
| 92 |
+
"tags": []
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"id": "mysql",
|
| 96 |
+
"label": "Mysql",
|
| 97 |
+
"domain": "backend",
|
| 98 |
+
"level": "intermediate",
|
| 99 |
+
"tags": []
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"id": "mongodb",
|
| 103 |
+
"label": "Mongodb",
|
| 104 |
+
"domain": "backend",
|
| 105 |
+
"level": "intermediate",
|
| 106 |
+
"tags": []
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"id": "docker",
|
| 110 |
+
"label": "Docker",
|
| 111 |
+
"domain": "backend",
|
| 112 |
+
"level": "intermediate",
|
| 113 |
+
"tags": []
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "kubernetes",
|
| 117 |
+
"label": "Kubernetes",
|
| 118 |
+
"domain": "backend",
|
| 119 |
+
"level": "intermediate",
|
| 120 |
+
"tags": []
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "github_actions",
|
| 124 |
+
"label": "Github Actions",
|
| 125 |
+
"domain": "backend",
|
| 126 |
+
"level": "intermediate",
|
| 127 |
+
"tags": []
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"id": "git",
|
| 131 |
+
"label": "Git",
|
| 132 |
+
"domain": "backend",
|
| 133 |
+
"level": "intermediate",
|
| 134 |
+
"tags": []
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"id": "linux_cli",
|
| 138 |
+
"label": "Linux Cli",
|
| 139 |
+
"domain": "backend",
|
| 140 |
+
"level": "intermediate",
|
| 141 |
+
"tags": []
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"id": "nginx",
|
| 145 |
+
"label": "Nginx",
|
| 146 |
+
"domain": "backend",
|
| 147 |
+
"level": "intermediate",
|
| 148 |
+
"tags": []
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"id": "jwt_auth",
|
| 152 |
+
"label": "Jwt Auth",
|
| 153 |
+
"domain": "backend",
|
| 154 |
+
"level": "intermediate",
|
| 155 |
+
"tags": []
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"id": "pytest",
|
| 159 |
+
"label": "Pytest",
|
| 160 |
+
"domain": "backend",
|
| 161 |
+
"level": "intermediate",
|
| 162 |
+
"tags": []
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"id": "sql",
|
| 166 |
+
"label": "Sql",
|
| 167 |
+
"domain": "data_engineering",
|
| 168 |
+
"level": "intermediate",
|
| 169 |
+
"tags": []
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"id": "dbt",
|
| 173 |
+
"label": "Dbt",
|
| 174 |
+
"domain": "data_engineering",
|
| 175 |
+
"level": "intermediate",
|
| 176 |
+
"tags": []
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"id": "airflow",
|
| 180 |
+
"label": "Airflow",
|
| 181 |
+
"domain": "data_engineering",
|
| 182 |
+
"level": "intermediate",
|
| 183 |
+
"tags": []
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"id": "prefect",
|
| 187 |
+
"label": "Prefect",
|
| 188 |
+
"domain": "data_engineering",
|
| 189 |
+
"level": "intermediate",
|
| 190 |
+
"tags": []
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"id": "spark",
|
| 194 |
+
"label": "Spark",
|
| 195 |
+
"domain": "data_engineering",
|
| 196 |
+
"level": "intermediate",
|
| 197 |
+
"tags": []
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"id": "kafka",
|
| 201 |
+
"label": "Kafka",
|
| 202 |
+
"domain": "data_engineering",
|
| 203 |
+
"level": "intermediate",
|
| 204 |
+
"tags": []
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"id": "pandas",
|
| 208 |
+
"label": "Pandas",
|
| 209 |
+
"domain": "data_engineering",
|
| 210 |
+
"level": "intermediate",
|
| 211 |
+
"tags": []
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"id": "polars",
|
| 215 |
+
"label": "Polars",
|
| 216 |
+
"domain": "data_engineering",
|
| 217 |
+
"level": "intermediate",
|
| 218 |
+
"tags": []
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"id": "numpy",
|
| 222 |
+
"label": "Numpy",
|
| 223 |
+
"domain": "data_engineering",
|
| 224 |
+
"level": "intermediate",
|
| 225 |
+
"tags": []
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"id": "pyspark",
|
| 229 |
+
"label": "Pyspark",
|
| 230 |
+
"domain": "data_engineering",
|
| 231 |
+
"level": "intermediate",
|
| 232 |
+
"tags": []
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"id": "snowflake",
|
| 236 |
+
"label": "Snowflake",
|
| 237 |
+
"domain": "data_engineering",
|
| 238 |
+
"level": "intermediate",
|
| 239 |
+
"tags": []
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"id": "bigquery",
|
| 243 |
+
"label": "Bigquery",
|
| 244 |
+
"domain": "data_engineering",
|
| 245 |
+
"level": "intermediate",
|
| 246 |
+
"tags": []
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
"id": "duckdb",
|
| 250 |
+
"label": "Duckdb",
|
| 251 |
+
"domain": "data_engineering",
|
| 252 |
+
"level": "intermediate",
|
| 253 |
+
"tags": []
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"id": "great_expectations",
|
| 257 |
+
"label": "Great Expectations",
|
| 258 |
+
"domain": "data_engineering",
|
| 259 |
+
"level": "intermediate",
|
| 260 |
+
"tags": []
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"id": "dagster",
|
| 264 |
+
"label": "Dagster",
|
| 265 |
+
"domain": "data_engineering",
|
| 266 |
+
"level": "intermediate",
|
| 267 |
+
"tags": []
|
| 268 |
+
},
|
| 269 |
+
{
|
| 270 |
+
"id": "data_modeling",
|
| 271 |
+
"label": "Data Modeling",
|
| 272 |
+
"domain": "data_engineering",
|
| 273 |
+
"level": "intermediate",
|
| 274 |
+
"tags": []
|
| 275 |
+
},
|
| 276 |
+
{
|
| 277 |
+
"id": "etl_pipelines",
|
| 278 |
+
"label": "Etl Pipelines",
|
| 279 |
+
"domain": "data_engineering",
|
| 280 |
+
"level": "intermediate",
|
| 281 |
+
"tags": []
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"id": "scikit_learn",
|
| 285 |
+
"label": "Scikit Learn",
|
| 286 |
+
"domain": "ml",
|
| 287 |
+
"level": "intermediate",
|
| 288 |
+
"tags": []
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
"id": "pytorch",
|
| 292 |
+
"label": "Pytorch",
|
| 293 |
+
"domain": "ml",
|
| 294 |
+
"level": "intermediate",
|
| 295 |
+
"tags": []
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
"id": "tensorflow",
|
| 299 |
+
"label": "Tensorflow",
|
| 300 |
+
"domain": "ml",
|
| 301 |
+
"level": "intermediate",
|
| 302 |
+
"tags": []
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"id": "huggingface_transformers",
|
| 306 |
+
"label": "Huggingface Transformers",
|
| 307 |
+
"domain": "ml",
|
| 308 |
+
"level": "intermediate",
|
| 309 |
+
"tags": []
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"id": "mlflow",
|
| 313 |
+
"label": "Mlflow",
|
| 314 |
+
"domain": "ml",
|
| 315 |
+
"level": "intermediate",
|
| 316 |
+
"tags": []
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"id": "bentoml",
|
| 320 |
+
"label": "Bentoml",
|
| 321 |
+
"domain": "ml",
|
| 322 |
+
"level": "intermediate",
|
| 323 |
+
"tags": []
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"id": "onnx",
|
| 327 |
+
"label": "Onnx",
|
| 328 |
+
"domain": "ml",
|
| 329 |
+
"level": "intermediate",
|
| 330 |
+
"tags": []
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"id": "feature_engineering",
|
| 334 |
+
"label": "Feature Engineering",
|
| 335 |
+
"domain": "ml",
|
| 336 |
+
"level": "intermediate",
|
| 337 |
+
"tags": []
|
| 338 |
+
},
|
| 339 |
+
{
|
| 340 |
+
"id": "model_evaluation",
|
| 341 |
+
"label": "Model Evaluation",
|
| 342 |
+
"domain": "ml",
|
| 343 |
+
"level": "intermediate",
|
| 344 |
+
"tags": []
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"id": "hyperparameter_tuning",
|
| 348 |
+
"label": "Hyperparameter Tuning",
|
| 349 |
+
"domain": "ml",
|
| 350 |
+
"level": "intermediate",
|
| 351 |
+
"tags": []
|
| 352 |
+
},
|
| 353 |
+
{
|
| 354 |
+
"id": "nlp_basics",
|
| 355 |
+
"label": "Nlp Basics",
|
| 356 |
+
"domain": "ml",
|
| 357 |
+
"level": "intermediate",
|
| 358 |
+
"tags": []
|
| 359 |
+
},
|
| 360 |
+
{
|
| 361 |
+
"id": "llm_prompting",
|
| 362 |
+
"label": "Llm Prompting",
|
| 363 |
+
"domain": "ml",
|
| 364 |
+
"level": "intermediate",
|
| 365 |
+
"tags": []
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"id": "langchain",
|
| 369 |
+
"label": "Langchain",
|
| 370 |
+
"domain": "ml",
|
| 371 |
+
"level": "intermediate",
|
| 372 |
+
"tags": []
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"id": "langgraph",
|
| 376 |
+
"label": "Langgraph",
|
| 377 |
+
"domain": "ml",
|
| 378 |
+
"level": "intermediate",
|
| 379 |
+
"tags": []
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"id": "vector_databases",
|
| 383 |
+
"label": "Vector Databases",
|
| 384 |
+
"domain": "ml",
|
| 385 |
+
"level": "intermediate",
|
| 386 |
+
"tags": []
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"id": "terraform",
|
| 390 |
+
"label": "Terraform",
|
| 391 |
+
"domain": "devops",
|
| 392 |
+
"level": "intermediate",
|
| 393 |
+
"tags": []
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
"id": "aws_ec2",
|
| 397 |
+
"label": "Aws Ec2",
|
| 398 |
+
"domain": "devops",
|
| 399 |
+
"level": "intermediate",
|
| 400 |
+
"tags": []
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"id": "aws_s3",
|
| 404 |
+
"label": "Aws S3",
|
| 405 |
+
"domain": "devops",
|
| 406 |
+
"level": "intermediate",
|
| 407 |
+
"tags": []
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"id": "aws_lambda",
|
| 411 |
+
"label": "Aws Lambda",
|
| 412 |
+
"domain": "devops",
|
| 413 |
+
"level": "intermediate",
|
| 414 |
+
"tags": []
|
| 415 |
+
},
|
| 416 |
+
{
|
| 417 |
+
"id": "aws_sagemaker",
|
| 418 |
+
"label": "Aws Sagemaker",
|
| 419 |
+
"domain": "devops",
|
| 420 |
+
"level": "intermediate",
|
| 421 |
+
"tags": []
|
| 422 |
+
},
|
| 423 |
+
{
|
| 424 |
+
"id": "gcp_vertex",
|
| 425 |
+
"label": "Gcp Vertex",
|
| 426 |
+
"domain": "devops",
|
| 427 |
+
"level": "intermediate",
|
| 428 |
+
"tags": []
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"id": "azure_ml",
|
| 432 |
+
"label": "Azure Ml",
|
| 433 |
+
"domain": "devops",
|
| 434 |
+
"level": "intermediate",
|
| 435 |
+
"tags": []
|
| 436 |
+
},
|
| 437 |
+
{
|
| 438 |
+
"id": "ci_cd",
|
| 439 |
+
"label": "Ci Cd",
|
| 440 |
+
"domain": "devops",
|
| 441 |
+
"level": "intermediate",
|
| 442 |
+
"tags": []
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"id": "prometheus",
|
| 446 |
+
"label": "Prometheus",
|
| 447 |
+
"domain": "devops",
|
| 448 |
+
"level": "intermediate",
|
| 449 |
+
"tags": []
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"id": "grafana",
|
| 453 |
+
"label": "Grafana",
|
| 454 |
+
"domain": "devops",
|
| 455 |
+
"level": "intermediate",
|
| 456 |
+
"tags": []
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"id": "helm",
|
| 460 |
+
"label": "Helm",
|
| 461 |
+
"domain": "devops",
|
| 462 |
+
"level": "intermediate",
|
| 463 |
+
"tags": []
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
"id": "ansible",
|
| 467 |
+
"label": "Ansible",
|
| 468 |
+
"domain": "devops",
|
| 469 |
+
"level": "intermediate",
|
| 470 |
+
"tags": []
|
| 471 |
+
}
|
| 472 |
+
],
|
| 473 |
+
"edges": [
|
| 474 |
+
{
|
| 475 |
+
"source": "python",
|
| 476 |
+
"target": "fastapi",
|
| 477 |
+
"type": "PREREQUISITE",
|
| 478 |
+
"weight": 0.8,
|
| 479 |
+
"hop_cost": 1
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"source": "python",
|
| 483 |
+
"target": "pandas",
|
| 484 |
+
"type": "PREREQUISITE",
|
| 485 |
+
"weight": 0.8,
|
| 486 |
+
"hop_cost": 1
|
| 487 |
+
},
|
| 488 |
+
{
|
| 489 |
+
"source": "pandas",
|
| 490 |
+
"target": "polars",
|
| 491 |
+
"type": "COUSIN",
|
| 492 |
+
"weight": 0.9,
|
| 493 |
+
"hop_cost": 1
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"source": "fastapi",
|
| 497 |
+
"target": "docker",
|
| 498 |
+
"type": "BRIDGES",
|
| 499 |
+
"weight": 0.5,
|
| 500 |
+
"hop_cost": 2
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"source": "git",
|
| 504 |
+
"target": "github_actions",
|
| 505 |
+
"type": "PREREQUISITE",
|
| 506 |
+
"weight": 0.8,
|
| 507 |
+
"hop_cost": 1
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"source": "sql",
|
| 511 |
+
"target": "dbt",
|
| 512 |
+
"type": "PREREQUISITE",
|
| 513 |
+
"weight": 0.8,
|
| 514 |
+
"hop_cost": 1
|
| 515 |
+
}
|
| 516 |
+
]
|
| 517 |
+
}
|
backend/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from routers import assessment, upload, roadmap
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
os.environ["LANGGRAPH_STRICT_MSGPACK"] = "false"
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
app = FastAPI(title="SkillForge AI", version="1.0.0")
|
| 12 |
+
|
| 13 |
+
app.add_middleware(
|
| 14 |
+
CORSMiddleware,
|
| 15 |
+
allow_origins=["http://localhost:3000"],
|
| 16 |
+
allow_credentials=True,
|
| 17 |
+
allow_methods=["*"],
|
| 18 |
+
allow_headers=["*"],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
app.include_router(upload.router)
|
| 22 |
+
app.include_router(assessment.router)
|
| 23 |
+
app.include_router(roadmap.router)
|
| 24 |
+
|
| 25 |
+
@app.get("/health")
|
| 26 |
+
async def health():
|
| 27 |
+
return {"status": "ok"}
|
backend/models/__pycache__/schemas.cpython-312.pyc
ADDED
|
Binary file (4.88 kB). View file
|
|
|
backend/models/schemas.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Literal
|
| 3 |
+
|
| 4 |
+
class JDSkill(BaseModel):
|
| 5 |
+
skill_id: str
|
| 6 |
+
label: str
|
| 7 |
+
priority: Literal["high", "medium", "low"]
|
| 8 |
+
years_required: int | None
|
| 9 |
+
context: str
|
| 10 |
+
|
| 11 |
+
class ResumeSkill(BaseModel):
|
| 12 |
+
skill_id: str
|
| 13 |
+
label: str
|
| 14 |
+
evidence_strength: float = Field(ge=0.0, le=1.0)
|
| 15 |
+
years_mentioned: int | None
|
| 16 |
+
context: str
|
| 17 |
+
|
| 18 |
+
class ExtractionResult(BaseModel):
|
| 19 |
+
jd_skills: list[JDSkill]
|
| 20 |
+
resume_skills: list[ResumeSkill]
|
| 21 |
+
seniority_level: Literal["junior", "mid", "senior", "lead"]
|
| 22 |
+
domain: Literal["backend", "data_engineering", "ml", "devops"]
|
| 23 |
+
|
| 24 |
+
class QuestionRecord(BaseModel):
|
| 25 |
+
skill_id: str
|
| 26 |
+
question: str
|
| 27 |
+
answer: str
|
| 28 |
+
score: float
|
| 29 |
+
signal: Literal["probe_deeper", "fire_challenge", "move_on"]
|
| 30 |
+
|
| 31 |
+
class ChallengeTask(BaseModel):
|
| 32 |
+
challenge_type: Literal["find_the_bug", "design_flaw", "sql_query"]
|
| 33 |
+
skill_id: str
|
| 34 |
+
context: str
|
| 35 |
+
task: str
|
| 36 |
+
expected_answer: str
|
| 37 |
+
difficulty: Literal["junior", "intermediate", "senior"]
|
| 38 |
+
|
| 39 |
+
class SkillScore(BaseModel):
|
| 40 |
+
skill_id: str
|
| 41 |
+
label: str
|
| 42 |
+
resume_evidence: float
|
| 43 |
+
conversation_score: float
|
| 44 |
+
final_score: float
|
| 45 |
+
gap_level: Literal["high_gap", "medium_gap", "ready"]
|
| 46 |
+
mismatch: bool
|
| 47 |
+
mismatch_severity: Literal["none", "mild", "significant"]
|
| 48 |
+
|
| 49 |
+
class RoadmapWeek(BaseModel):
|
| 50 |
+
week: int
|
| 51 |
+
skill_id: str
|
| 52 |
+
label: str
|
| 53 |
+
tier: Literal[1, 2, 3]
|
| 54 |
+
resources: list[dict]
|
| 55 |
+
mini_project: str
|
| 56 |
+
graph_path: list[str]
|
| 57 |
+
why: str
|
| 58 |
+
|
| 59 |
+
class AssessmentResult(BaseModel):
|
| 60 |
+
assessment_id: str
|
| 61 |
+
extraction: ExtractionResult
|
| 62 |
+
skill_scores: list[SkillScore]
|
| 63 |
+
roadmap: list[RoadmapWeek]
|
| 64 |
+
time_to_ready_weeks: float
|
| 65 |
+
domain: str
|
| 66 |
+
|
| 67 |
+
class UploadResponse(BaseModel):
|
| 68 |
+
text: str
|
| 69 |
+
filename: str
|
| 70 |
+
|
| 71 |
+
class StartAssessmentRequest(BaseModel):
|
| 72 |
+
jd_text: str
|
| 73 |
+
resume_text: str
|
| 74 |
+
hours_per_day: float = 2.0
|
| 75 |
+
assessment_id: str | None = None
|
| 76 |
+
|
| 77 |
+
class StartAssessmentResponse(BaseModel):
|
| 78 |
+
assessment_id: str
|
| 79 |
+
extraction: ExtractionResult
|
| 80 |
+
message: str
|
| 81 |
+
|
| 82 |
+
class StreamEvent(BaseModel):
|
| 83 |
+
event: Literal[
|
| 84 |
+
"question", "challenge", "skill_complete",
|
| 85 |
+
"assessment_complete", "error", "extraction_complete"
|
| 86 |
+
]
|
| 87 |
+
skill_id: str | None = None
|
| 88 |
+
skill_label: str | None = None
|
| 89 |
+
content: str | None = None
|
| 90 |
+
progress: float | None = None
|
| 91 |
+
data: dict | None = None
|
backend/output/__pycache__/roadmap_generator.cpython-312.pyc
ADDED
|
Binary file (3.52 kB). View file
|
|
|
backend/output/roadmap_generator.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from models.schemas import RoadmapWeek, SkillScore
|
| 4 |
+
from knowledge_graph.graph_engine import get_engine
|
| 5 |
+
|
| 6 |
+
def generate_roadmap(
|
| 7 |
+
skill_scores: list[SkillScore],
|
| 8 |
+
graph_paths: dict[str, list[str]],
|
| 9 |
+
hours_per_day: float = 2.0,
|
| 10 |
+
) -> list[RoadmapWeek]:
|
| 11 |
+
engine = get_engine()
|
| 12 |
+
resources_path = Path("backend/knowledge_graph/resources.json")
|
| 13 |
+
if resources_path.exists():
|
| 14 |
+
with open(resources_path, "r", encoding="utf-8") as f:
|
| 15 |
+
resources_db = json.load(f)
|
| 16 |
+
else:
|
| 17 |
+
resources_db = {}
|
| 18 |
+
|
| 19 |
+
roadmap = []
|
| 20 |
+
base_hours_per_week = hours_per_day * 5
|
| 21 |
+
week_counter = 1
|
| 22 |
+
|
| 23 |
+
# Sort skills: high_gap first, then medium_gap
|
| 24 |
+
gap_skills = [s for s in skill_scores if s.gap_level in ("high_gap", "medium_gap")]
|
| 25 |
+
gap_skills.sort(key=lambda s: 0 if s.gap_level == "high_gap" else 1)
|
| 26 |
+
|
| 27 |
+
for skill in gap_skills:
|
| 28 |
+
skill_id = skill.skill_id
|
| 29 |
+
path = graph_paths.get(skill_id, [])
|
| 30 |
+
path_str = " → ".join(path)
|
| 31 |
+
|
| 32 |
+
resources = []
|
| 33 |
+
if skill_id in resources_db:
|
| 34 |
+
resources = resources_db[skill_id].get("courses", [])
|
| 35 |
+
|
| 36 |
+
why_msg = "Direct learning path"
|
| 37 |
+
if len(path) > 1:
|
| 38 |
+
source = path[0]
|
| 39 |
+
target = path[-1]
|
| 40 |
+
hops = len(path) - 1
|
| 41 |
+
has_edges = True if engine.graph.has_node(source) and engine.graph.has_node(target) else False
|
| 42 |
+
if has_edges:
|
| 43 |
+
edge_type = "path"
|
| 44 |
+
try:
|
| 45 |
+
if hops == 1:
|
| 46 |
+
edge_type = engine.graph[source][target]["type"]
|
| 47 |
+
except:
|
| 48 |
+
pass
|
| 49 |
+
why_msg = f"You already know {source} → {target} is {edge_type} ({hops} hop)"
|
| 50 |
+
else:
|
| 51 |
+
why_msg = f"Building up from {source} to {target}"
|
| 52 |
+
|
| 53 |
+
roadmap.append(RoadmapWeek(
|
| 54 |
+
week=week_counter,
|
| 55 |
+
skill_id=skill_id,
|
| 56 |
+
label=skill.label,
|
| 57 |
+
tier=1,
|
| 58 |
+
resources=resources,
|
| 59 |
+
mini_project=f"Build a basic script using {skill.label} core features",
|
| 60 |
+
graph_path=path,
|
| 61 |
+
why=why_msg
|
| 62 |
+
))
|
| 63 |
+
week_counter += 1
|
| 64 |
+
|
| 65 |
+
roadmap.append(RoadmapWeek(
|
| 66 |
+
week=week_counter,
|
| 67 |
+
skill_id=skill_id,
|
| 68 |
+
label=skill.label,
|
| 69 |
+
tier=2,
|
| 70 |
+
resources=[],
|
| 71 |
+
mini_project=f"Integrate {skill.label} into a broader project",
|
| 72 |
+
graph_path=path,
|
| 73 |
+
why=why_msg
|
| 74 |
+
))
|
| 75 |
+
week_counter += 1
|
| 76 |
+
|
| 77 |
+
roadmap.append(RoadmapWeek(
|
| 78 |
+
week=week_counter,
|
| 79 |
+
skill_id=skill_id,
|
| 80 |
+
label=skill.label,
|
| 81 |
+
tier=3,
|
| 82 |
+
resources=[],
|
| 83 |
+
mini_project=f"Solve a role-specific scenario with {skill.label}",
|
| 84 |
+
graph_path=path,
|
| 85 |
+
why=why_msg
|
| 86 |
+
))
|
| 87 |
+
week_counter += 1
|
| 88 |
+
|
| 89 |
+
return roadmap
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
langgraph>=0.1.0
|
| 4 |
+
langchain-groq>=0.1.0
|
| 5 |
+
langchain-core>=0.2.0
|
| 6 |
+
networkx>=3.3
|
| 7 |
+
pdfplumber>=0.11.0
|
| 8 |
+
python-dotenv>=1.0.0
|
| 9 |
+
python-multipart>=0.0.9
|
| 10 |
+
pydantic>=2.7.0
|
| 11 |
+
pytest>=8.0.0
|
| 12 |
+
httpx>=0.27.0
|
backend/routers/__pycache__/assessment.cpython-312.pyc
ADDED
|
Binary file (4.46 kB). View file
|
|
|
backend/routers/__pycache__/roadmap.cpython-312.pyc
ADDED
|
Binary file (1.75 kB). View file
|
|
|
backend/routers/__pycache__/upload.cpython-312.pyc
ADDED
|
Binary file (2.05 kB). View file
|
|
|
backend/routers/assessment.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from models.schemas import (
|
| 4 |
+
StartAssessmentRequest, StartAssessmentResponse, StreamEvent
|
| 5 |
+
)
|
| 6 |
+
from graph_pipeline.pipeline import pipeline
|
| 7 |
+
from graph_pipeline.state import AssessmentState
|
| 8 |
+
import uuid, json, asyncio
|
| 9 |
+
from typing import Dict
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/assess", tags=["assessment"])
|
| 12 |
+
|
| 13 |
+
active_assessments: Dict[str, dict] = {}
|
| 14 |
+
|
| 15 |
+
@router.post("/", response_model=StartAssessmentResponse)
|
| 16 |
+
async def start_assessment(body: StartAssessmentRequest):
|
| 17 |
+
assessment_id = body.assessment_id if body.assessment_id else str(uuid.uuid4())
|
| 18 |
+
|
| 19 |
+
config = {"configurable": {"thread_id": assessment_id}}
|
| 20 |
+
active_assessments[assessment_id] = config
|
| 21 |
+
|
| 22 |
+
state = AssessmentState(
|
| 23 |
+
assessment_id=assessment_id,
|
| 24 |
+
jd_text=body.jd_text,
|
| 25 |
+
resume_text=body.resume_text,
|
| 26 |
+
hours_per_day=body.hours_per_day,
|
| 27 |
+
extraction=None,
|
| 28 |
+
gap_skill_ids=[],
|
| 29 |
+
graph_paths={},
|
| 30 |
+
current_skill_index=0,
|
| 31 |
+
questions_asked=0,
|
| 32 |
+
questions_per_skill=3,
|
| 33 |
+
question_records=[],
|
| 34 |
+
conversation_scores={},
|
| 35 |
+
challenge_task=None,
|
| 36 |
+
pending_answer=None,
|
| 37 |
+
stream_events=[],
|
| 38 |
+
skill_scores=[],
|
| 39 |
+
roadmap=[],
|
| 40 |
+
assessment_complete=False
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
pipeline.invoke(state, config)
|
| 44 |
+
|
| 45 |
+
current_state = pipeline.get_state(config)
|
| 46 |
+
st = current_state.values
|
| 47 |
+
|
| 48 |
+
extraction = st.get("extraction")
|
| 49 |
+
|
| 50 |
+
return StartAssessmentResponse(
|
| 51 |
+
assessment_id=assessment_id,
|
| 52 |
+
extraction=extraction,
|
| 53 |
+
message="Assessment started"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@router.post("/{assessment_id}/answer")
|
| 57 |
+
async def submit_answer(assessment_id: str, answer: str):
|
| 58 |
+
if assessment_id not in active_assessments:
|
| 59 |
+
raise HTTPException(status_code=404, detail="Assessment not found")
|
| 60 |
+
|
| 61 |
+
config = active_assessments[assessment_id]
|
| 62 |
+
|
| 63 |
+
pipeline.update_state(config, {"pending_answer": answer})
|
| 64 |
+
pipeline.invoke(None, config)
|
| 65 |
+
|
| 66 |
+
return {"status": "ok"}
|
| 67 |
+
|
| 68 |
+
@router.get("/{assessment_id}/stream")
|
| 69 |
+
async def stream_events(assessment_id: str):
|
| 70 |
+
if assessment_id not in active_assessments:
|
| 71 |
+
raise HTTPException(status_code=404, detail="Assessment not found")
|
| 72 |
+
|
| 73 |
+
config = active_assessments[assessment_id]
|
| 74 |
+
|
| 75 |
+
async def event_generator():
|
| 76 |
+
last_event_idx = 0
|
| 77 |
+
while True:
|
| 78 |
+
current_state = pipeline.get_state(config)
|
| 79 |
+
st = current_state.values
|
| 80 |
+
if not st:
|
| 81 |
+
await asyncio.sleep(0.2)
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
events = st.get("stream_events", [])
|
| 85 |
+
while last_event_idx < len(events):
|
| 86 |
+
event = events[last_event_idx]
|
| 87 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 88 |
+
last_event_idx += 1
|
| 89 |
+
|
| 90 |
+
if st.get("assessment_complete"):
|
| 91 |
+
break
|
| 92 |
+
|
| 93 |
+
await asyncio.sleep(0.2)
|
| 94 |
+
|
| 95 |
+
return StreamingResponse(
|
| 96 |
+
event_generator(),
|
| 97 |
+
media_type="text/event-stream",
|
| 98 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
| 99 |
+
)
|
backend/routers/roadmap.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from models.schemas import AssessmentResult
|
| 3 |
+
from graph_pipeline.pipeline import pipeline
|
| 4 |
+
from routers.assessment import active_assessments
|
| 5 |
+
from output.roadmap_generator import generate_roadmap
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/roadmap", tags=["roadmap"])
|
| 8 |
+
|
| 9 |
+
@router.get("/{assessment_id}", response_model=AssessmentResult)
|
| 10 |
+
async def get_roadmap(assessment_id: str, hours_per_day: float = 2.0):
|
| 11 |
+
if assessment_id not in active_assessments:
|
| 12 |
+
raise HTTPException(status_code=404, detail="Assessment not found")
|
| 13 |
+
|
| 14 |
+
config = active_assessments[assessment_id]
|
| 15 |
+
current_state = pipeline.get_state(config)
|
| 16 |
+
st = current_state.values
|
| 17 |
+
|
| 18 |
+
if not st.get("assessment_complete"):
|
| 19 |
+
raise HTTPException(status_code=400, detail="Assessment not complete")
|
| 20 |
+
|
| 21 |
+
roadmap = generate_roadmap(
|
| 22 |
+
st.get("skill_scores", []),
|
| 23 |
+
st.get("graph_paths", {}),
|
| 24 |
+
hours_per_day
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
return AssessmentResult(
|
| 28 |
+
assessment_id=assessment_id,
|
| 29 |
+
extraction=st["extraction"],
|
| 30 |
+
skill_scores=st.get("skill_scores", []),
|
| 31 |
+
roadmap=roadmap,
|
| 32 |
+
time_to_ready_weeks=len(roadmap),
|
| 33 |
+
domain=st["extraction"].domain
|
| 34 |
+
)
|
backend/routers/upload.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, HTTPException
|
| 2 |
+
from models.schemas import UploadResponse
|
| 3 |
+
import pdfplumber, io
|
| 4 |
+
|
| 5 |
+
router = APIRouter(prefix="/upload", tags=["upload"])
|
| 6 |
+
|
| 7 |
+
def extract_text_from_upload(file: UploadFile) -> str:
|
| 8 |
+
content = file.file.read()
|
| 9 |
+
if file.filename.endswith(".pdf"):
|
| 10 |
+
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
| 11 |
+
text = "\n".join([page.extract_text() for page in pdf.pages if page.extract_text()])
|
| 12 |
+
return text
|
| 13 |
+
else:
|
| 14 |
+
return content.decode("utf-8", errors="ignore")
|
| 15 |
+
|
| 16 |
+
@router.post("/jd", response_model=UploadResponse)
|
| 17 |
+
async def upload_jd(file: UploadFile = File(...)):
|
| 18 |
+
text = extract_text_from_upload(file)
|
| 19 |
+
return UploadResponse(text=text, filename=file.filename)
|
| 20 |
+
|
| 21 |
+
@router.post("/resume", response_model=UploadResponse)
|
| 22 |
+
async def upload_resume(file: UploadFile = File(...)):
|
| 23 |
+
text = extract_text_from_upload(file)
|
| 24 |
+
return UploadResponse(text=text, filename=file.filename)
|
backend/scoring/__pycache__/claim_vs_reality.cpython-312.pyc
ADDED
|
Binary file (860 Bytes). View file
|
|
|
backend/scoring/__pycache__/gap_classifier.cpython-312.pyc
ADDED
|
Binary file (434 Bytes). View file
|
|
|
backend/scoring/claim_vs_reality.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def detect_mismatch(
|
| 2 |
+
skill_id: str,
|
| 3 |
+
resume_evidence: float,
|
| 4 |
+
conversation_score: float,
|
| 5 |
+
threshold: float = 0.3,
|
| 6 |
+
) -> dict:
|
| 7 |
+
delta = resume_evidence - conversation_score
|
| 8 |
+
mismatch = delta > threshold
|
| 9 |
+
|
| 10 |
+
if delta < 0.3:
|
| 11 |
+
severity = "none"
|
| 12 |
+
label = "Verified"
|
| 13 |
+
elif delta <= 0.5:
|
| 14 |
+
severity = "mild"
|
| 15 |
+
label = "Slight overstatement"
|
| 16 |
+
else:
|
| 17 |
+
severity = "significant"
|
| 18 |
+
label = "Significant inflation detected"
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
"skill_id": skill_id,
|
| 22 |
+
"mismatch": mismatch,
|
| 23 |
+
"delta": round(delta, 3),
|
| 24 |
+
"severity": severity,
|
| 25 |
+
"label": label
|
| 26 |
+
}
|
backend/scoring/gap_classifier.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def classify_gap(final_score: float) -> str:
|
| 2 |
+
if final_score < 0.3:
|
| 3 |
+
return "high_gap"
|
| 4 |
+
elif final_score < 0.6:
|
| 5 |
+
return "medium_gap"
|
| 6 |
+
else:
|
| 7 |
+
return "ready"
|
frontend/app/assess/[id]/page.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useParams, useRouter } from "next/navigation"
|
| 4 |
+
import { useStore } from "@/lib/store"
|
| 5 |
+
import { submitAnswer } from "@/lib/api"
|
| 6 |
+
import { useAssessmentStream } from "@/hooks/useAssessmentStream"
|
| 7 |
+
import ChatInterface from "@/components/assessment/ChatInterface"
|
| 8 |
+
import ProgressBar from "@/components/assessment/ProgressBar"
|
| 9 |
+
|
| 10 |
+
export default function AssessPage() {
|
| 11 |
+
const params = useParams()
|
| 12 |
+
const id = typeof params.id === "string" ? params.id : null
|
| 13 |
+
const router = useRouter()
|
| 14 |
+
const [mounted, setMounted] = useState(false)
|
| 15 |
+
useEffect(() => setMounted(true), [])
|
| 16 |
+
|
| 17 |
+
const { isConnected, error, isComplete } = useAssessmentStream(id)
|
| 18 |
+
|
| 19 |
+
const { messages, extraction, addMessage, currentSkillId } = useStore()
|
| 20 |
+
const [isWaiting, setIsWaiting] = useState(false)
|
| 21 |
+
|
| 22 |
+
const handleSend = async (answer: string) => {
|
| 23 |
+
if (!id) return
|
| 24 |
+
setIsWaiting(true)
|
| 25 |
+
addMessage({
|
| 26 |
+
id: Math.random().toString(36).substr(2, 9),
|
| 27 |
+
role: "user",
|
| 28 |
+
content: answer,
|
| 29 |
+
timestamp: new Date()
|
| 30 |
+
})
|
| 31 |
+
try {
|
| 32 |
+
await submitAnswer(id, answer)
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.error(err)
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
const lastMsg = messages[messages.length - 1]
|
| 40 |
+
if (lastMsg && (lastMsg.role === "agent" || lastMsg.role === "challenge")) {
|
| 41 |
+
setIsWaiting(false)
|
| 42 |
+
}
|
| 43 |
+
}, [messages])
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (isComplete) {
|
| 47 |
+
router.push(`/results/${id}`)
|
| 48 |
+
}
|
| 49 |
+
}, [isComplete, id, router])
|
| 50 |
+
|
| 51 |
+
const jdSkills = extraction?.jd_skills || []
|
| 52 |
+
const resumeSkills = extraction?.resume_skills || []
|
| 53 |
+
|
| 54 |
+
const gapSkills = jdSkills.filter(jd => {
|
| 55 |
+
const rs = resumeSkills.find(r => r.skill_id === jd.skill_id)
|
| 56 |
+
return !rs || rs.evidence_strength < 0.6
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
const currentIdx = currentSkillId ? gapSkills.findIndex(s => s.skill_id === currentSkillId) : 0
|
| 60 |
+
const progressPct = gapSkills.length > 0 ? ((currentIdx) / gapSkills.length) * 100 : 0
|
| 61 |
+
|
| 62 |
+
if (!mounted) return <div className="h-screen bg-bg"></div>
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="flex h-screen bg-bg text-text">
|
| 66 |
+
<div className="w-[30%] p-6 border-r border-border overflow-y-auto hidden md:block">
|
| 67 |
+
<h2 className="text-xl font-mono text-accent mb-8">SkillForge Assessment</h2>
|
| 68 |
+
|
| 69 |
+
<ProgressBar current={currentIdx} total={gapSkills.length} label="ASSESSMENT PROGRESS" />
|
| 70 |
+
|
| 71 |
+
<div className="mt-8 space-y-4">
|
| 72 |
+
<h3 className="text-xs text-muted uppercase tracking-wider font-mono">Skills to verify</h3>
|
| 73 |
+
{gapSkills.map((s, idx) => (
|
| 74 |
+
<div key={s.skill_id} className={`p-4 rounded-lg border transition-colors ${
|
| 75 |
+
s.skill_id === currentSkillId ? "border-accent bg-accent/5" : "border-border bg-surface"
|
| 76 |
+
} ${idx < currentIdx ? "opacity-50" : ""}`}>
|
| 77 |
+
<div className="flex justify-between items-center">
|
| 78 |
+
<span className={`font-mono text-sm ${s.skill_id === currentSkillId ? "text-accent" : "text-text"}`}>{s.label}</span>
|
| 79 |
+
{idx < currentIdx && <span className="text-xs text-muted">Complete</span>}
|
| 80 |
+
{s.skill_id === currentSkillId && <span className="w-2 h-2 rounded-full bg-accent animate-pulse"></span>}
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
))}
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="w-full md:w-[70%] h-full">
|
| 88 |
+
{error ? (
|
| 89 |
+
<div className="flex items-center justify-center h-full text-red-500 font-mono">
|
| 90 |
+
{error}. <button onClick={() => window.location.reload()} className="ml-4 underline">Retry</button>
|
| 91 |
+
<button onClick={() => router.push(`/results/${id}`)} className="ml-4 underline text-accent">Go to Results</button>
|
| 92 |
+
</div>
|
| 93 |
+
) : (
|
| 94 |
+
<ChatInterface messages={messages} onSend={handleSend} isWaiting={isWaiting} />
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
)
|
| 99 |
+
}
|
frontend/app/candidate/job/[id]/page.tsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useParams, useRouter } from "next/navigation"
|
| 4 |
+
import { createClient } from "@/utils/supabase/client"
|
| 5 |
+
import { useStore } from "@/lib/store"
|
| 6 |
+
import { startAssessment } from "@/lib/api"
|
| 7 |
+
import UploadZone from "@/components/upload/UploadZone"
|
| 8 |
+
|
| 9 |
+
export default function CandidateJobPage() {
|
| 10 |
+
const params = useParams()
|
| 11 |
+
const id = typeof params.id === "string" ? params.id : null
|
| 12 |
+
const router = useRouter()
|
| 13 |
+
const supabase = createClient()
|
| 14 |
+
|
| 15 |
+
const { hoursPerDay, setHoursPerDay, setJdText, resumeText, setResumeText, setAssessmentId, setExtraction } = useStore()
|
| 16 |
+
|
| 17 |
+
const [job, setJob] = useState<any>(null)
|
| 18 |
+
const [loading, setLoading] = useState(true)
|
| 19 |
+
const [starting, setStarting] = useState(false)
|
| 20 |
+
const [error, setError] = useState("")
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (!id) return
|
| 24 |
+
fetchJob()
|
| 25 |
+
}, [id])
|
| 26 |
+
|
| 27 |
+
const fetchJob = async () => {
|
| 28 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 29 |
+
if (!user) {
|
| 30 |
+
router.push('/login')
|
| 31 |
+
return
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const { data } = await supabase.from('jobs').select('*, profiles(email)').eq('id', id).single()
|
| 35 |
+
if (data) {
|
| 36 |
+
setJob(data)
|
| 37 |
+
setJdText(data.jd_text)
|
| 38 |
+
}
|
| 39 |
+
setLoading(false)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const handleBegin = async () => {
|
| 43 |
+
if (!job || !resumeText) return
|
| 44 |
+
setStarting(true)
|
| 45 |
+
setError("")
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 49 |
+
if (!user) throw new Error("Not authenticated")
|
| 50 |
+
|
| 51 |
+
// 1. Create Assessment Row in Supabase
|
| 52 |
+
const { data: assessment, error: dbError } = await supabase.from('assessments').insert({
|
| 53 |
+
job_id: job.id,
|
| 54 |
+
candidate_id: user.id,
|
| 55 |
+
resume_text: resumeText,
|
| 56 |
+
status: 'in_progress'
|
| 57 |
+
}).select().single()
|
| 58 |
+
|
| 59 |
+
if (dbError) throw dbError
|
| 60 |
+
|
| 61 |
+
// 2. Start Python Backend Assessment with that same Supabase ID
|
| 62 |
+
const res = await startAssessment(job.jd_text, resumeText, hoursPerDay, assessment.id)
|
| 63 |
+
|
| 64 |
+
// 3. Save to Zustand store and navigate
|
| 65 |
+
setAssessmentId(assessment.id)
|
| 66 |
+
setExtraction(res.extraction)
|
| 67 |
+
router.push(`/assess/${assessment.id}`)
|
| 68 |
+
|
| 69 |
+
} catch (err: any) {
|
| 70 |
+
setError(err.message)
|
| 71 |
+
setStarting(false)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if (loading || !job) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Job...</div>
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 79 |
+
<div className="max-w-4xl mx-auto space-y-8">
|
| 80 |
+
<button onClick={() => router.push('/candidate')} className="text-sm font-mono text-muted hover:text-accent transition-colors">
|
| 81 |
+
← Back to Portal
|
| 82 |
+
</button>
|
| 83 |
+
|
| 84 |
+
<header className="border-b border-border/40 pb-6">
|
| 85 |
+
<h1 className="text-3xl font-bold font-mono tracking-tighter mb-2 text-accent">{job.title}</h1>
|
| 86 |
+
<p className="text-muted font-mono text-sm">Posted by: {job.profiles?.email}</p>
|
| 87 |
+
</header>
|
| 88 |
+
|
| 89 |
+
<div className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl space-y-8">
|
| 90 |
+
<div>
|
| 91 |
+
<h2 className="text-xl font-mono text-text mb-4 flex items-center">
|
| 92 |
+
<span className="w-8 h-px bg-accent/50 mr-4"></span>
|
| 93 |
+
Apply & Test
|
| 94 |
+
</h2>
|
| 95 |
+
<p className="text-muted text-sm mb-6">
|
| 96 |
+
Upload your resume below to begin. Our AI will instantly evaluate your resume against this job description and conduct a dynamic technical interview.
|
| 97 |
+
</p>
|
| 98 |
+
|
| 99 |
+
<div className="max-w-lg">
|
| 100 |
+
<UploadZone label="Your Candidate Resume (PDF)" endpoint="resume" onUpload={setResumeText} />
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div className="max-w-lg bg-background/50 border border-border/30 p-5 rounded-xl">
|
| 105 |
+
<label className="flex justify-between text-sm font-mono text-muted mb-4">
|
| 106 |
+
<span>Availability (hours/day)</span>
|
| 107 |
+
<span className="text-accent font-bold">{hoursPerDay}h</span>
|
| 108 |
+
</label>
|
| 109 |
+
<input
|
| 110 |
+
type="range" min="1" max="8" step="0.5"
|
| 111 |
+
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 112 |
+
className="w-full h-1.5 bg-background rounded-lg appearance-none cursor-pointer accent-accent transition-all hover:h-2"
|
| 113 |
+
/>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{error && <div className="text-red-500 font-mono text-sm">{error}</div>}
|
| 117 |
+
|
| 118 |
+
<button
|
| 119 |
+
onClick={handleBegin}
|
| 120 |
+
disabled={!resumeText || starting}
|
| 121 |
+
className="bg-accent text-background px-8 py-4 rounded-xl font-mono font-bold text-lg hover:scale-[1.02] shadow-[0_0_20px_rgba(232,255,107,0.2)] disabled:opacity-50 disabled:hover:scale-100 transition-all"
|
| 122 |
+
>
|
| 123 |
+
{starting ? "Initializing Engine..." : "Begin AI Interview →"}
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
)
|
| 129 |
+
}
|
frontend/app/candidate/page.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useRouter } from "next/navigation"
|
| 4 |
+
import { createClient } from "@/utils/supabase/client"
|
| 5 |
+
|
| 6 |
+
export default function CandidateDashboard() {
|
| 7 |
+
const router = useRouter()
|
| 8 |
+
const supabase = createClient()
|
| 9 |
+
const [jobs, setJobs] = useState<any[]>([])
|
| 10 |
+
const [loading, setLoading] = useState(true)
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
fetchJobs()
|
| 14 |
+
}, [])
|
| 15 |
+
|
| 16 |
+
const fetchJobs = async () => {
|
| 17 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 18 |
+
if (!user) {
|
| 19 |
+
router.push('/login')
|
| 20 |
+
return
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
|
| 24 |
+
if (profile?.role !== 'candidate') {
|
| 25 |
+
router.push('/employer')
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Fetch all jobs
|
| 30 |
+
const { data } = await supabase.from('jobs').select('*, profiles(email)').order('created_at', { ascending: false })
|
| 31 |
+
if (data) setJobs(data)
|
| 32 |
+
setLoading(false)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const handleLogout = async () => {
|
| 36 |
+
await supabase.auth.signOut()
|
| 37 |
+
router.push('/')
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (loading) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Opportunities...</div>
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 44 |
+
<div className="max-w-4xl mx-auto space-y-12">
|
| 45 |
+
<header className="flex justify-between items-end border-b border-border/40 pb-6">
|
| 46 |
+
<div>
|
| 47 |
+
<h1 className="text-4xl font-bold font-mono tracking-tighter mb-2">Candidate <span className="text-accent">Portal</span></h1>
|
| 48 |
+
<p className="text-muted font-mono">Discover roles and prove your skills</p>
|
| 49 |
+
</div>
|
| 50 |
+
<button onClick={handleLogout} className="text-sm font-mono text-muted hover:text-red-400 transition-colors">Log Out</button>
|
| 51 |
+
</header>
|
| 52 |
+
|
| 53 |
+
<section className="space-y-6">
|
| 54 |
+
<h2 className="text-2xl font-mono text-text">Open Positions</h2>
|
| 55 |
+
|
| 56 |
+
{jobs.length === 0 ? (
|
| 57 |
+
<div className="border border-dashed border-border/50 rounded-2xl p-12 text-center text-muted font-mono">
|
| 58 |
+
No open positions at the moment. Check back soon!
|
| 59 |
+
</div>
|
| 60 |
+
) : (
|
| 61 |
+
<div className="grid grid-cols-1 gap-4">
|
| 62 |
+
{jobs.map(job => (
|
| 63 |
+
<div key={job.id} className="bg-surface/40 backdrop-blur-md border border-border/40 p-6 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4 hover:border-accent/50 transition-colors">
|
| 64 |
+
<div>
|
| 65 |
+
<h3 className="text-xl font-mono text-accent mb-2">{job.title}</h3>
|
| 66 |
+
<p className="text-xs font-mono text-muted">Posted by: {job.profiles?.email}</p>
|
| 67 |
+
<p className="text-xs font-mono text-muted mt-1">{new Date(job.created_at).toLocaleDateString()}</p>
|
| 68 |
+
</div>
|
| 69 |
+
<button
|
| 70 |
+
onClick={() => router.push(`/candidate/job/${job.id}`)}
|
| 71 |
+
className="bg-accent/10 text-accent border border-accent/30 px-6 py-2 rounded-lg font-mono font-bold hover:bg-accent hover:text-background transition-all"
|
| 72 |
+
>
|
| 73 |
+
Apply & Assess →
|
| 74 |
+
</button>
|
| 75 |
+
</div>
|
| 76 |
+
))}
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
</section>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
)
|
| 83 |
+
}
|
frontend/app/employer/job/[id]/page.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useParams, useRouter } from "next/navigation"
|
| 4 |
+
import { createClient } from "@/utils/supabase/client"
|
| 5 |
+
|
| 6 |
+
export default function EmployerJobPage() {
|
| 7 |
+
const params = useParams()
|
| 8 |
+
const id = typeof params.id === "string" ? params.id : null
|
| 9 |
+
const router = useRouter()
|
| 10 |
+
const supabase = createClient()
|
| 11 |
+
|
| 12 |
+
const [job, setJob] = useState<any>(null)
|
| 13 |
+
const [assessments, setAssessments] = useState<any[]>([])
|
| 14 |
+
const [loading, setLoading] = useState(true)
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
if (!id) return
|
| 18 |
+
fetchJobAndCandidates()
|
| 19 |
+
}, [id])
|
| 20 |
+
|
| 21 |
+
const fetchJobAndCandidates = async () => {
|
| 22 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 23 |
+
if (!user) {
|
| 24 |
+
router.push('/login')
|
| 25 |
+
return
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Fetch job details
|
| 29 |
+
const { data: jobData } = await supabase.from('jobs').select('*').eq('id', id).single()
|
| 30 |
+
if (jobData) setJob(jobData)
|
| 31 |
+
|
| 32 |
+
// Fetch assessments for this job
|
| 33 |
+
const { data: assessmentData } = await supabase.from('assessments')
|
| 34 |
+
.select('id, status, created_at, result_data, profiles(email)')
|
| 35 |
+
.eq('job_id', id)
|
| 36 |
+
|
| 37 |
+
if (assessmentData) {
|
| 38 |
+
const mapped = assessmentData.map((a: any) => {
|
| 39 |
+
let score = 0;
|
| 40 |
+
if (a.result_data && a.result_data.skill_scores && a.result_data.skill_scores.length > 0) {
|
| 41 |
+
score = Math.round((a.result_data.skill_scores.reduce((sum: number, s: any) => sum + s.final_score, 0) / a.result_data.skill_scores.length) * 100)
|
| 42 |
+
}
|
| 43 |
+
return { ...a, score }
|
| 44 |
+
})
|
| 45 |
+
// Sort by score descending
|
| 46 |
+
mapped.sort((a, b) => b.score - a.score)
|
| 47 |
+
setAssessments(mapped)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
setLoading(false)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (loading || !job) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Job Details...</div>
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 57 |
+
<div className="max-w-5xl mx-auto space-y-8">
|
| 58 |
+
<button onClick={() => router.push('/employer')} className="text-sm font-mono text-muted hover:text-accent transition-colors">
|
| 59 |
+
← Back to Dashboard
|
| 60 |
+
</button>
|
| 61 |
+
|
| 62 |
+
<header className="border-b border-border/40 pb-6 flex justify-between items-end">
|
| 63 |
+
<div>
|
| 64 |
+
<h1 className="text-3xl font-bold font-mono tracking-tighter mb-2 text-accent">{job.title}</h1>
|
| 65 |
+
<p className="text-muted font-mono text-sm">Posted on {new Date(job.created_at).toLocaleDateString()}</p>
|
| 66 |
+
</div>
|
| 67 |
+
<div className="bg-surface/50 border border-border/50 px-4 py-2 rounded-lg">
|
| 68 |
+
<span className="font-mono text-xl text-text font-bold">{assessments.length}</span>
|
| 69 |
+
<span className="text-muted text-xs uppercase ml-2">Candidates</span>
|
| 70 |
+
</div>
|
| 71 |
+
</header>
|
| 72 |
+
|
| 73 |
+
<section className="space-y-6">
|
| 74 |
+
<h2 className="text-2xl font-mono text-text">Candidate Assessments</h2>
|
| 75 |
+
|
| 76 |
+
{assessments.length === 0 ? (
|
| 77 |
+
<div className="border border-dashed border-border/50 rounded-2xl p-12 text-center text-muted font-mono bg-surface/20">
|
| 78 |
+
No candidates have applied to this job yet.
|
| 79 |
+
</div>
|
| 80 |
+
) : (
|
| 81 |
+
<div className="grid grid-cols-1 gap-4">
|
| 82 |
+
{assessments.map((assessment) => (
|
| 83 |
+
<div key={assessment.id} className="bg-surface/40 backdrop-blur-md border border-border/40 p-6 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4 hover:border-accent/50 transition-colors">
|
| 84 |
+
<div>
|
| 85 |
+
<h3 className="text-lg font-mono text-text mb-1">{assessment.profiles?.email}</h3>
|
| 86 |
+
<div className="flex items-center gap-3">
|
| 87 |
+
<span className={`text-xs font-mono px-2 py-1 rounded-md border ${
|
| 88 |
+
assessment.status === 'completed'
|
| 89 |
+
? 'border-green-500/50 text-green-400 bg-green-500/10'
|
| 90 |
+
: 'border-yellow-500/50 text-yellow-400 bg-yellow-500/10'
|
| 91 |
+
}`}>
|
| 92 |
+
{assessment.status === 'completed' ? 'COMPLETED' : 'IN PROGRESS'}
|
| 93 |
+
</span>
|
| 94 |
+
{assessment.status === 'completed' && (
|
| 95 |
+
<span className="text-xs font-mono px-2 py-1 rounded-md border border-accent/50 text-accent bg-accent/10">
|
| 96 |
+
SCORE: {assessment.score}/100
|
| 97 |
+
</span>
|
| 98 |
+
)}
|
| 99 |
+
<span className="text-xs text-muted font-mono">{new Date(assessment.created_at).toLocaleString()}</span>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<button
|
| 104 |
+
disabled={assessment.status !== 'completed'}
|
| 105 |
+
onClick={() => router.push(`/results/${assessment.id}`)}
|
| 106 |
+
className="bg-background border border-border text-text px-6 py-2 rounded-lg font-mono font-bold hover:border-accent hover:text-accent transition-all disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text"
|
| 107 |
+
>
|
| 108 |
+
{assessment.status === 'completed' ? "View Results →" : "Awaiting Completion"}
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
))}
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</section>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
)
|
| 118 |
+
}
|
frontend/app/employer/page.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useRouter } from "next/navigation"
|
| 4 |
+
import { createClient } from "@/utils/supabase/client"
|
| 5 |
+
import UploadZone from "@/components/upload/UploadZone"
|
| 6 |
+
|
| 7 |
+
export default function EmployerDashboard() {
|
| 8 |
+
const router = useRouter()
|
| 9 |
+
const supabase = createClient()
|
| 10 |
+
const [jobs, setJobs] = useState<any[]>([])
|
| 11 |
+
const [loading, setLoading] = useState(true)
|
| 12 |
+
const [jdText, setJdText] = useState("")
|
| 13 |
+
const [jobTitle, setJobTitle] = useState("")
|
| 14 |
+
const [creating, setCreating] = useState(false)
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
fetchJobs()
|
| 18 |
+
}, [])
|
| 19 |
+
|
| 20 |
+
const fetchJobs = async () => {
|
| 21 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 22 |
+
if (!user) {
|
| 23 |
+
router.push('/login')
|
| 24 |
+
return
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
|
| 28 |
+
if (profile?.role !== 'employer') {
|
| 29 |
+
router.push('/candidate')
|
| 30 |
+
return
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const { data } = await supabase.from('jobs').select('*').order('created_at', { ascending: false })
|
| 34 |
+
if (data) setJobs(data)
|
| 35 |
+
setLoading(false)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const handleCreateJob = async () => {
|
| 39 |
+
if (!jobTitle || !jdText) return
|
| 40 |
+
setCreating(true)
|
| 41 |
+
|
| 42 |
+
const { data: { user } } = await supabase.auth.getUser()
|
| 43 |
+
if (!user) return
|
| 44 |
+
|
| 45 |
+
const { error } = await supabase.from('jobs').insert({
|
| 46 |
+
employer_id: user.id,
|
| 47 |
+
title: jobTitle,
|
| 48 |
+
jd_text: jdText
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
setCreating(false)
|
| 52 |
+
if (!error) {
|
| 53 |
+
setJobTitle("")
|
| 54 |
+
setJdText("")
|
| 55 |
+
fetchJobs()
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const handleLogout = async () => {
|
| 60 |
+
await supabase.auth.signOut()
|
| 61 |
+
router.push('/')
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (loading) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Dashboard...</div>
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 68 |
+
<div className="max-w-6xl mx-auto space-y-12">
|
| 69 |
+
<header className="flex justify-between items-end border-b border-border/40 pb-6">
|
| 70 |
+
<div>
|
| 71 |
+
<h1 className="text-4xl font-bold font-mono tracking-tighter mb-2">Employer <span className="text-accent">Portal</span></h1>
|
| 72 |
+
<p className="text-muted font-mono">Manage jobs and review candidate assessments</p>
|
| 73 |
+
</div>
|
| 74 |
+
<button onClick={handleLogout} className="text-sm font-mono text-muted hover:text-red-400 transition-colors">Log Out</button>
|
| 75 |
+
</header>
|
| 76 |
+
|
| 77 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 78 |
+
<div className="lg:col-span-1 space-y-6">
|
| 79 |
+
<div className="bg-surface/40 backdrop-blur-md border border-border/40 p-6 rounded-2xl">
|
| 80 |
+
<h2 className="text-xl font-mono text-accent mb-6">Post New Job</h2>
|
| 81 |
+
|
| 82 |
+
<div className="space-y-4">
|
| 83 |
+
<div>
|
| 84 |
+
<label className="block text-xs font-mono text-muted mb-2">Job Title</label>
|
| 85 |
+
<input
|
| 86 |
+
type="text"
|
| 87 |
+
value={jobTitle}
|
| 88 |
+
onChange={(e) => setJobTitle(e.target.value)}
|
| 89 |
+
className="w-full bg-background border border-border/50 rounded-xl p-3 text-sm focus:border-accent outline-none"
|
| 90 |
+
placeholder="e.g. Senior Frontend Engineer"
|
| 91 |
+
/>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="pt-2">
|
| 95 |
+
<UploadZone label="Job Description (PDF)" endpoint="jd" onUpload={setJdText} />
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<button
|
| 99 |
+
onClick={handleCreateJob}
|
| 100 |
+
disabled={creating || !jobTitle || !jdText}
|
| 101 |
+
className="w-full bg-accent text-background font-bold font-mono py-3 rounded-xl hover:brightness-110 disabled:opacity-50 mt-4"
|
| 102 |
+
>
|
| 103 |
+
{creating ? "Posting..." : "Create Job"}
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div className="lg:col-span-2 space-y-6">
|
| 110 |
+
<h2 className="text-2xl font-mono text-text">Your Active Jobs</h2>
|
| 111 |
+
|
| 112 |
+
{jobs.length === 0 ? (
|
| 113 |
+
<div className="border border-dashed border-border/50 rounded-2xl p-12 text-center text-muted font-mono">
|
| 114 |
+
No jobs posted yet. Post your first job to start receiving candidates!
|
| 115 |
+
</div>
|
| 116 |
+
) : (
|
| 117 |
+
<div className="space-y-4">
|
| 118 |
+
{jobs.map(job => (
|
| 119 |
+
<div key={job.id} className="bg-surface/40 border border-border/40 p-6 rounded-2xl hover:border-accent/50 transition-colors flex justify-between items-center group cursor-pointer" onClick={() => router.push(`/employer/job/${job.id}`)}>
|
| 120 |
+
<div>
|
| 121 |
+
<h3 className="text-lg font-mono text-accent mb-1">{job.title}</h3>
|
| 122 |
+
<p className="text-xs text-muted font-mono">Posted {new Date(job.created_at).toLocaleDateString()}</p>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="text-muted group-hover:text-accent group-hover:translate-x-1 transition-all">
|
| 125 |
+
View Candidates →
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
))}
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
)
|
| 136 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg: #030014;
|
| 7 |
+
--surface: rgba(13, 10, 31, 0.7);
|
| 8 |
+
--border: #221c46;
|
| 9 |
+
--accent: #00f0ff;
|
| 10 |
+
--text: #ffffff;
|
| 11 |
+
--muted: #8e8b9f;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
background-color: var(--bg);
|
| 16 |
+
color: var(--text);
|
| 17 |
+
background-image:
|
| 18 |
+
radial-gradient(circle at 15% 0%, rgba(112, 0, 255, 0.15) 0%, transparent 40%),
|
| 19 |
+
radial-gradient(circle at 85% 100%, rgba(0, 240, 255, 0.15) 0%, transparent 40%);
|
| 20 |
+
background-attachment: fixed;
|
| 21 |
+
min-height: 100vh;
|
| 22 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"
|
| 2 |
+
import type { Metadata } from "next"
|
| 3 |
+
import "./globals.css"
|
| 4 |
+
|
| 5 |
+
const plexMono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500", "700"], variable: "--font-mono" })
|
| 6 |
+
const plexSans = IBM_Plex_Sans({ subsets: ["latin"], weight: ["400", "500", "700"], variable: "--font-sans" })
|
| 7 |
+
|
| 8 |
+
export const metadata: Metadata = {
|
| 9 |
+
title: "SkillForge AI",
|
| 10 |
+
description: "From resume claims to real capability.",
|
| 11 |
+
colorScheme: "dark",
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 15 |
+
return (
|
| 16 |
+
<html lang="en" className={`${plexMono.variable} ${plexSans.variable}`}>
|
| 17 |
+
<body className="bg-background text-text font-sans antialiased min-h-screen">
|
| 18 |
+
{children}
|
| 19 |
+
</body>
|
| 20 |
+
</html>
|
| 21 |
+
)
|
| 22 |
+
}
|
frontend/app/login/page.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useState } from "react"
|
| 3 |
+
import { useRouter } from "next/navigation"
|
| 4 |
+
import { createClient } from "@/utils/supabase/client"
|
| 5 |
+
|
| 6 |
+
export default function LoginPage() {
|
| 7 |
+
const router = useRouter()
|
| 8 |
+
const supabase = createClient()
|
| 9 |
+
|
| 10 |
+
const [isLogin, setIsLogin] = useState(true)
|
| 11 |
+
const [email, setEmail] = useState("")
|
| 12 |
+
const [password, setPassword] = useState("")
|
| 13 |
+
const [role, setRole] = useState("candidate")
|
| 14 |
+
const [loading, setLoading] = useState(false)
|
| 15 |
+
const [error, setError] = useState("")
|
| 16 |
+
|
| 17 |
+
const handleAuth = async (e: React.FormEvent) => {
|
| 18 |
+
e.preventDefault()
|
| 19 |
+
setLoading(true)
|
| 20 |
+
setError("")
|
| 21 |
+
|
| 22 |
+
// Strict check for allowed email domains
|
| 23 |
+
const allowedDomains = ["@gmail.com", "@yahoo.com", "@outlook.com", "@hotmail.com", "@icloud.com"]
|
| 24 |
+
const hasValidDomain = allowedDomains.some(domain => email.toLowerCase().endsWith(domain))
|
| 25 |
+
|
| 26 |
+
if (!hasValidDomain) {
|
| 27 |
+
setError("Please use a standard email provider (e.g., @gmail.com, @yahoo.com)")
|
| 28 |
+
setLoading(false)
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (isLogin) {
|
| 33 |
+
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
| 34 |
+
if (error) {
|
| 35 |
+
setError(error.message)
|
| 36 |
+
setLoading(false)
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Fetch role
|
| 41 |
+
const { data: profile } = await supabase.from('profiles').select('role').eq('id', data.user.id).single()
|
| 42 |
+
if (profile?.role === 'employer') {
|
| 43 |
+
router.push('/employer')
|
| 44 |
+
} else {
|
| 45 |
+
router.push('/candidate')
|
| 46 |
+
}
|
| 47 |
+
} else {
|
| 48 |
+
const { data, error } = await supabase.auth.signUp({ email, password })
|
| 49 |
+
if (error) {
|
| 50 |
+
setError(error.message)
|
| 51 |
+
setLoading(false)
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
if (data.user) {
|
| 55 |
+
const { error: profileError } = await supabase.from('profiles').insert({
|
| 56 |
+
id: data.user.id,
|
| 57 |
+
email,
|
| 58 |
+
role
|
| 59 |
+
})
|
| 60 |
+
if (profileError) {
|
| 61 |
+
setError(profileError.message)
|
| 62 |
+
setLoading(false)
|
| 63 |
+
return
|
| 64 |
+
}
|
| 65 |
+
if (role === 'employer') {
|
| 66 |
+
router.push('/employer')
|
| 67 |
+
} else {
|
| 68 |
+
router.push('/candidate')
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="min-h-screen relative bg-bg text-text flex items-center justify-center p-6 overflow-hidden">
|
| 76 |
+
<div className="fixed inset-0 pointer-events-none bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:4rem_4rem] z-0"></div>
|
| 77 |
+
<div className="fixed inset-0 pointer-events-none z-0" style={{ background: "radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.05) 0%, transparent 70%)" }}></div>
|
| 78 |
+
|
| 79 |
+
<div className="relative z-10 max-w-md w-full bg-surface/60 backdrop-blur-xl border border-border/50 p-8 rounded-3xl shadow-[0_4px_30px_rgba(0,0,0,0.3)]">
|
| 80 |
+
<h1 className="text-4xl font-bold font-mono text-center tracking-tighter mb-2">
|
| 81 |
+
SkillForge<span className="text-accent">.ai</span>
|
| 82 |
+
</h1>
|
| 83 |
+
<h2 className="text-lg text-center text-muted font-mono mb-8">{isLogin ? "Access your portal" : "Create your account"}</h2>
|
| 84 |
+
|
| 85 |
+
{error && <div className="bg-red-500/10 border border-red-500/50 text-red-500 p-4 rounded-xl mb-6 text-sm font-mono">{error}</div>}
|
| 86 |
+
|
| 87 |
+
<form onSubmit={handleAuth} className="space-y-5">
|
| 88 |
+
<div>
|
| 89 |
+
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Email Address</label>
|
| 90 |
+
<input
|
| 91 |
+
type="email"
|
| 92 |
+
required
|
| 93 |
+
value={email}
|
| 94 |
+
onChange={e => setEmail(e.target.value)}
|
| 95 |
+
className="w-full bg-background border border-border/50 rounded-xl p-3 text-sm focus:outline-none focus:border-accent transition-colors"
|
| 96 |
+
placeholder="you@example.com"
|
| 97 |
+
/>
|
| 98 |
+
</div>
|
| 99 |
+
<div>
|
| 100 |
+
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Password</label>
|
| 101 |
+
<input
|
| 102 |
+
type="password"
|
| 103 |
+
required
|
| 104 |
+
value={password}
|
| 105 |
+
onChange={e => setPassword(e.target.value)}
|
| 106 |
+
className="w-full bg-background border border-border/50 rounded-xl p-3 text-sm focus:outline-none focus:border-accent transition-colors"
|
| 107 |
+
placeholder="••••••••"
|
| 108 |
+
/>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{!isLogin && (
|
| 112 |
+
<div>
|
| 113 |
+
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">I am a...</label>
|
| 114 |
+
<div className="flex gap-4">
|
| 115 |
+
<button
|
| 116 |
+
type="button"
|
| 117 |
+
onClick={() => setRole("candidate")}
|
| 118 |
+
className={`flex-1 p-3 rounded-xl border font-mono transition-all duration-300 ${role === "candidate" ? "border-accent bg-accent/10 text-accent shadow-[0_0_15px_rgba(0,240,255,0.15)]" : "border-border/50 bg-background text-muted hover:border-border"}`}
|
| 119 |
+
>
|
| 120 |
+
Candidate
|
| 121 |
+
</button>
|
| 122 |
+
<button
|
| 123 |
+
type="button"
|
| 124 |
+
onClick={() => setRole("employer")}
|
| 125 |
+
className={`flex-1 p-3 rounded-xl border font-mono transition-all duration-300 ${role === "employer" ? "border-accent bg-accent/10 text-accent shadow-[0_0_15px_rgba(0,240,255,0.15)]" : "border-border/50 bg-background text-muted hover:border-border"}`}
|
| 126 |
+
>
|
| 127 |
+
Employer
|
| 128 |
+
</button>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
<button
|
| 134 |
+
type="submit"
|
| 135 |
+
disabled={loading}
|
| 136 |
+
className="w-full bg-accent text-background font-bold font-mono py-4 rounded-xl hover:scale-[1.02] shadow-[0_0_20px_rgba(0,240,255,0.2)] disabled:opacity-50 disabled:hover:scale-100 transition-all mt-4"
|
| 137 |
+
>
|
| 138 |
+
{loading ? "Authenticating..." : (isLogin ? "Secure Login →" : "Create Account →")}
|
| 139 |
+
</button>
|
| 140 |
+
</form>
|
| 141 |
+
|
| 142 |
+
<div className="text-center mt-8 text-muted text-sm font-mono">
|
| 143 |
+
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
| 144 |
+
<button onClick={() => setIsLogin(!isLogin)} className="text-accent hover:underline">
|
| 145 |
+
{isLogin ? "Sign Up" : "Log In"}
|
| 146 |
+
</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)
|
| 151 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useRouter } from "next/navigation"
|
| 3 |
+
|
| 4 |
+
export default function LandingPage() {
|
| 5 |
+
const router = useRouter()
|
| 6 |
+
|
| 7 |
+
return (
|
| 8 |
+
<div className="min-h-screen relative overflow-hidden flex flex-col items-center justify-center p-6 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:4rem_4rem]">
|
| 9 |
+
<div className="absolute inset-0 pointer-events-none" style={{ background: "radial-gradient(circle at center, transparent 0%, var(--bg) 100%)" }}></div>
|
| 10 |
+
|
| 11 |
+
<div className="relative z-10 w-full max-w-4xl flex flex-col items-center text-center">
|
| 12 |
+
<h1 className="text-5xl md:text-7xl font-bold font-mono text-text mb-4 tracking-tighter">SkillForge<span className="text-accent">.ai</span></h1>
|
| 13 |
+
<p className="text-xl text-muted mb-12 font-mono max-w-2xl">
|
| 14 |
+
The next-generation technical assessment platform. Move beyond basic resume parsing and test real capability.
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
<div className="flex flex-col md:flex-row gap-6 w-full max-w-2xl">
|
| 18 |
+
<div className="flex-1 bg-surface/60 backdrop-blur-xl border border-border/50 p-8 rounded-3xl flex flex-col items-center">
|
| 19 |
+
<h2 className="text-2xl font-mono text-accent mb-4">For Employers</h2>
|
| 20 |
+
<p className="text-muted text-sm mb-8 text-center">Post jobs, set requirements, and let AI conduct the first-round technical interviews for you.</p>
|
| 21 |
+
<button
|
| 22 |
+
onClick={() => router.push('/login')}
|
| 23 |
+
className="w-full bg-background border border-border text-text px-6 py-3 rounded-xl font-mono hover:border-accent hover:text-accent transition-colors"
|
| 24 |
+
>
|
| 25 |
+
Employer Portal →
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div className="flex-1 bg-surface/60 backdrop-blur-xl border border-border/50 p-8 rounded-3xl flex flex-col items-center">
|
| 30 |
+
<h2 className="text-2xl font-mono text-accent mb-4">For Candidates</h2>
|
| 31 |
+
<p className="text-muted text-sm mb-8 text-center">Apply to open roles, take interactive AI-driven technical assessments, and prove your skills.</p>
|
| 32 |
+
<button
|
| 33 |
+
onClick={() => router.push('/login')}
|
| 34 |
+
className="w-full bg-accent text-background px-6 py-3 rounded-xl font-mono font-bold hover:scale-[1.02] shadow-[0_0_20px_rgba(0,240,255,0.2)] transition-all"
|
| 35 |
+
>
|
| 36 |
+
Candidate Portal →
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
)
|
| 43 |
+
}
|
frontend/app/results/[id]/page.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useEffect, useState } from "react"
|
| 3 |
+
import { useParams, useRouter } from "next/navigation"
|
| 4 |
+
import { useStore } from "@/lib/store"
|
| 5 |
+
import { getRoadmap } from "@/lib/api"
|
| 6 |
+
import { createClient } from "@/utils/supabase/client"
|
| 7 |
+
import SkillHeatmap from "@/components/results/SkillHeatmap"
|
| 8 |
+
import RoadmapTimeline from "@/components/results/RoadmapTimeline"
|
| 9 |
+
import GraphPathViz from "@/components/results/GraphPathViz"
|
| 10 |
+
|
| 11 |
+
export default function ResultsPage() {
|
| 12 |
+
const params = useParams()
|
| 13 |
+
const id = typeof params.id === "string" ? params.id : null
|
| 14 |
+
const router = useRouter()
|
| 15 |
+
const { hoursPerDay, setHoursPerDay, result, setResult, resetAssessment } = useStore()
|
| 16 |
+
const [mounted, setMounted] = useState(false)
|
| 17 |
+
const [error, setError] = useState("")
|
| 18 |
+
const [resumeText, setResumeText] = useState("")
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
setMounted(true)
|
| 22 |
+
if (!id) return
|
| 23 |
+
|
| 24 |
+
const loadResults = async () => {
|
| 25 |
+
const supabase = createClient()
|
| 26 |
+
|
| 27 |
+
// 1. Try to load completed result from Supabase
|
| 28 |
+
const { data: assessment } = await supabase.from('assessments').select('status, result_data, resume_text').eq('id', id).single()
|
| 29 |
+
|
| 30 |
+
if (assessment) {
|
| 31 |
+
setResumeText(assessment.resume_text || "")
|
| 32 |
+
if (assessment.status === 'completed' && assessment.result_data) {
|
| 33 |
+
setResult(assessment.result_data)
|
| 34 |
+
return
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// 2. If not completed or not in Supabase, try to fetch from Python backend (live computation)
|
| 39 |
+
try {
|
| 40 |
+
const res = await getRoadmap(id, hoursPerDay)
|
| 41 |
+
setResult(res)
|
| 42 |
+
|
| 43 |
+
// 3. Save the newly computed result to Supabase
|
| 44 |
+
await supabase.from('assessments').update({
|
| 45 |
+
status: 'completed',
|
| 46 |
+
result_data: res
|
| 47 |
+
}).eq('id', id)
|
| 48 |
+
} catch (err) {
|
| 49 |
+
console.error("Failed to load roadmap:", err)
|
| 50 |
+
setError("This assessment session was interrupted or wiped from memory before completion.")
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
loadResults()
|
| 55 |
+
}, [id, hoursPerDay, setResult, router])
|
| 56 |
+
|
| 57 |
+
if (error) {
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen flex flex-col items-center justify-center bg-bg font-mono text-center p-6 space-y-6">
|
| 60 |
+
<h1 className="text-3xl font-bold text-red-500">Session Expired</h1>
|
| 61 |
+
<p className="text-muted max-w-md">{error}</p>
|
| 62 |
+
<button onClick={() => router.back()} className="text-accent border border-accent/50 px-6 py-2 rounded-lg hover:bg-accent/10">Go Back</button>
|
| 63 |
+
</div>
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (!mounted || !result) {
|
| 68 |
+
return (
|
| 69 |
+
<div className="min-h-screen flex items-center justify-center bg-bg font-mono text-accent animate-pulse">
|
| 70 |
+
Compiling Results...
|
| 71 |
+
</div>
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const overallScore = result?.skill_scores?.length > 0
|
| 75 |
+
? Math.round((result.skill_scores.reduce((sum: number, s: any) => sum + s.final_score, 0) / result.skill_scores.length) * 100)
|
| 76 |
+
: 0;
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="min-h-screen relative bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 80 |
+
{/* Background Gradients */}
|
| 81 |
+
<div className="fixed inset-0 pointer-events-none bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:4rem_4rem] z-0"></div>
|
| 82 |
+
<div className="fixed inset-0 pointer-events-none z-0" style={{ background: "radial-gradient(circle at 50% 0%, rgba(0, 240, 255, 0.05) 0%, transparent 70%)" }}></div>
|
| 83 |
+
|
| 84 |
+
<div className="relative z-10 max-w-6xl mx-auto space-y-16">
|
| 85 |
+
{/* Header */}
|
| 86 |
+
<header className="flex flex-col md:flex-row md:items-end justify-between border-b border-border/40 pb-8 gap-6 backdrop-blur-md bg-bg/40 p-8 rounded-2xl border shadow-lg">
|
| 87 |
+
<div>
|
| 88 |
+
<h1 className="text-4xl md:text-5xl font-bold font-mono tracking-tighter mb-3">
|
| 89 |
+
<span className="text-accent">Assessment</span> Results
|
| 90 |
+
</h1>
|
| 91 |
+
<p className="text-muted font-mono flex items-center">
|
| 92 |
+
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse"></span>
|
| 93 |
+
Profile: {result.extraction.seniority_level} {result.extraction.domain}
|
| 94 |
+
</p>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div className="bg-surface/60 backdrop-blur-xl border border-border/50 p-5 rounded-xl w-full md:w-72 shadow-[0_4px_20px_rgba(0,0,0,0.2)]">
|
| 98 |
+
<label className="flex justify-between text-sm font-mono text-muted mb-3">
|
| 99 |
+
<span>Availability</span>
|
| 100 |
+
<span className="text-accent font-bold">{hoursPerDay}h/day</span>
|
| 101 |
+
</label>
|
| 102 |
+
<input
|
| 103 |
+
type="range" min="1" max="8" step="0.5"
|
| 104 |
+
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 105 |
+
className="w-full h-1.5 bg-background rounded-lg appearance-none cursor-pointer accent-accent transition-all hover:h-2"
|
| 106 |
+
/>
|
| 107 |
+
</div>
|
| 108 |
+
</header>
|
| 109 |
+
|
| 110 |
+
{/* Score Overview */}
|
| 111 |
+
<section className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500 flex flex-col items-center">
|
| 112 |
+
<h2 className="text-2xl font-mono text-accent mb-8">Overall Capability Score</h2>
|
| 113 |
+
<div className="relative w-48 h-48 flex items-center justify-center">
|
| 114 |
+
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
| 115 |
+
<circle cx="50" cy="50" r="40" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-border" />
|
| 116 |
+
<circle cx="50" cy="50" r="40" stroke="currentColor" strokeWidth="8" fill="transparent" strokeDasharray="251.2" strokeDashoffset={251.2 - (overallScore/100)*251.2} className="text-accent transition-all duration-1000 ease-out" />
|
| 117 |
+
</svg>
|
| 118 |
+
<div className="absolute flex flex-col items-center justify-center">
|
| 119 |
+
<span className="text-5xl font-bold font-mono text-text">{overallScore}</span>
|
| 120 |
+
<span className="text-sm text-accent font-mono mt-1">/ 100</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</section>
|
| 124 |
+
|
| 125 |
+
{/* Skill Map */}
|
| 126 |
+
<section className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500">
|
| 127 |
+
<h2 className="text-2xl font-mono text-accent mb-8 flex items-center">
|
| 128 |
+
<span className="w-10 h-px bg-accent/50 mr-4"></span>
|
| 129 |
+
Skill Map
|
| 130 |
+
<span className="ml-4 text-xs font-sans text-muted bg-surface px-3 py-1 rounded-full border border-border/50 hidden sm:inline-block">Performance Overview</span>
|
| 131 |
+
</h2>
|
| 132 |
+
<SkillHeatmap skillScores={result.skill_scores} />
|
| 133 |
+
</section>
|
| 134 |
+
|
| 135 |
+
{/* Learning Path */}
|
| 136 |
+
{result.roadmap.filter(r => r.tier === 1).length > 0 && (
|
| 137 |
+
<section className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500">
|
| 138 |
+
<h2 className="text-2xl font-mono text-accent mb-8 flex items-center">
|
| 139 |
+
<span className="w-10 h-px bg-accent/50 mr-4"></span>
|
| 140 |
+
Prerequisite Pathways
|
| 141 |
+
</h2>
|
| 142 |
+
<div className="grid grid-cols-1 gap-6">
|
| 143 |
+
{result.roadmap.filter(r => r.tier === 1).map(r => (
|
| 144 |
+
<div key={r.skill_id} className="bg-background/80 border border-border/50 rounded-xl p-6 transition-transform hover:-translate-y-1 duration-300">
|
| 145 |
+
<h3 className="font-mono text-text mb-4 flex items-center">
|
| 146 |
+
<div className="w-2 h-2 bg-accent rounded-sm mr-3"></div>
|
| 147 |
+
{r.label}
|
| 148 |
+
</h3>
|
| 149 |
+
<GraphPathViz path={r.graph_path} edgeTypes={Array(r.graph_path.length - 1).fill("PREREQUISITE")} />
|
| 150 |
+
</div>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
</section>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
{/* Roadmap */}
|
| 157 |
+
<section className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500">
|
| 158 |
+
<h2 className="text-2xl font-mono text-accent mb-8 flex items-center">
|
| 159 |
+
<span className="w-10 h-px bg-accent/50 mr-4"></span>
|
| 160 |
+
Personalized Roadmap
|
| 161 |
+
</h2>
|
| 162 |
+
<RoadmapTimeline roadmap={result.roadmap} />
|
| 163 |
+
</section>
|
| 164 |
+
|
| 165 |
+
{/* Resume View */}
|
| 166 |
+
{resumeText && (
|
| 167 |
+
<section className="bg-surface/40 backdrop-blur-md border border-border/40 p-8 rounded-3xl shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500">
|
| 168 |
+
<h2 className="text-2xl font-mono text-accent mb-8 flex items-center">
|
| 169 |
+
<span className="w-10 h-px bg-accent/50 mr-4"></span>
|
| 170 |
+
Candidate Resume
|
| 171 |
+
</h2>
|
| 172 |
+
<div className="bg-background/80 border border-border/50 rounded-xl p-6 max-h-[500px] overflow-y-auto font-mono text-sm text-muted whitespace-pre-wrap leading-relaxed">
|
| 173 |
+
{resumeText}
|
| 174 |
+
</div>
|
| 175 |
+
</section>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
<section className="flex justify-center mt-16 pb-16">
|
| 179 |
+
<button
|
| 180 |
+
onClick={() => {
|
| 181 |
+
resetAssessment()
|
| 182 |
+
router.push('/')
|
| 183 |
+
}}
|
| 184 |
+
className="group relative inline-flex items-center justify-center px-8 py-4 font-mono font-bold text-background bg-accent rounded-xl overflow-hidden transition-all hover:scale-105 shadow-[0_0_20px_rgba(0,240,255,0.2)] hover:shadow-[0_0_40px_rgba(0,240,255,0.4)]"
|
| 185 |
+
>
|
| 186 |
+
<span className="relative z-10 flex items-center">
|
| 187 |
+
Start New Assessment
|
| 188 |
+
<span className="ml-2 group-hover:translate-x-1 transition-transform">→</span>
|
| 189 |
+
</span>
|
| 190 |
+
</button>
|
| 191 |
+
</section>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
)
|
| 195 |
+
}
|
frontend/components/assessment/ChatInterface.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useState, useRef, useEffect } from "react"
|
| 3 |
+
import type { ChatMessage } from "@/lib/types"
|
| 4 |
+
import ReactMarkdown from "react-markdown"
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
messages: ChatMessage[]
|
| 8 |
+
onSend: (answer: string) => void
|
| 9 |
+
isWaiting: boolean
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function ChatInterface({ messages, onSend, isWaiting }: Props) {
|
| 13 |
+
const [input, setInput] = useState("")
|
| 14 |
+
const endRef = useRef<HTMLDivElement>(null)
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
endRef.current?.scrollIntoView({ behavior: "smooth" })
|
| 18 |
+
}, [messages])
|
| 19 |
+
|
| 20 |
+
const handleSend = () => {
|
| 21 |
+
if (!input.trim() || isWaiting) return
|
| 22 |
+
onSend(input)
|
| 23 |
+
setInput("")
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const renderAgentMessage = (content: string) => {
|
| 27 |
+
const lines = content.split('\n')
|
| 28 |
+
const options: string[] = []
|
| 29 |
+
const mainText: string[] = []
|
| 30 |
+
|
| 31 |
+
const optionRegex = /^[-*]?\s*([A-D])[\)\.]\s+(.*)/i;
|
| 32 |
+
|
| 33 |
+
for (const line of lines) {
|
| 34 |
+
const match = line.trim().match(optionRegex)
|
| 35 |
+
if (match) {
|
| 36 |
+
options.push(line.trim())
|
| 37 |
+
} else {
|
| 38 |
+
mainText.push(line)
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="space-y-4">
|
| 44 |
+
<div className="prose prose-invert max-w-none text-sm font-sans">
|
| 45 |
+
<ReactMarkdown>{mainText.join('\n')}</ReactMarkdown>
|
| 46 |
+
</div>
|
| 47 |
+
{options.length > 0 && (
|
| 48 |
+
<div className="flex flex-col space-y-2 mt-4">
|
| 49 |
+
{options.map((opt, i) => {
|
| 50 |
+
const cleanOpt = opt.replace(/^[-*]?\s*/, '')
|
| 51 |
+
const match = cleanOpt.match(/^([A-D])[\)\.]/i)
|
| 52 |
+
const letter = match ? match[1] : cleanOpt
|
| 53 |
+
return (
|
| 54 |
+
<button
|
| 55 |
+
key={i}
|
| 56 |
+
onClick={() => {
|
| 57 |
+
if (!isWaiting) onSend(letter.toUpperCase())
|
| 58 |
+
}}
|
| 59 |
+
disabled={isWaiting}
|
| 60 |
+
className="text-left p-3 rounded-lg border border-border bg-surface hover:border-accent hover:bg-accent/5 transition-colors text-sm font-mono text-text disabled:opacity-50 disabled:cursor-not-allowed text-wrap break-words"
|
| 61 |
+
>
|
| 62 |
+
{cleanOpt}
|
| 63 |
+
</button>
|
| 64 |
+
)
|
| 65 |
+
})}
|
| 66 |
+
</div>
|
| 67 |
+
)}
|
| 68 |
+
</div>
|
| 69 |
+
)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="flex flex-col h-full bg-surface border-l border-border">
|
| 74 |
+
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
| 75 |
+
{messages.map((m) => (
|
| 76 |
+
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
| 77 |
+
{m.role === "agent" && (
|
| 78 |
+
<div className="w-8 h-8 rounded-full bg-accent text-background flex items-center justify-center font-bold mr-3 shrink-0">SF</div>
|
| 79 |
+
)}
|
| 80 |
+
|
| 81 |
+
{m.role === "challenge" ? (
|
| 82 |
+
<div className="w-full bg-background border border-red-500/30 rounded-lg p-4">
|
| 83 |
+
<div className="text-red-400 font-mono text-sm mb-2 font-bold uppercase">Challenge</div>
|
| 84 |
+
<div className="prose prose-invert max-w-none text-sm font-sans">
|
| 85 |
+
<ReactMarkdown>{m.content}</ReactMarkdown>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="mt-4 text-xs text-muted">Submit your answer below</div>
|
| 88 |
+
</div>
|
| 89 |
+
) : m.role === "system" ? (
|
| 90 |
+
<div className="w-full text-center text-muted text-xs italic">{m.content}</div>
|
| 91 |
+
) : m.role === "user" ? (
|
| 92 |
+
<div className="max-w-[80%] p-4 rounded-xl text-sm bg-accent/10 text-accent ml-12">
|
| 93 |
+
<ReactMarkdown>{m.content}</ReactMarkdown>
|
| 94 |
+
</div>
|
| 95 |
+
) : (
|
| 96 |
+
<div className="max-w-[80%] p-4 rounded-xl text-sm bg-background text-text border border-border/50 shadow-sm">
|
| 97 |
+
{renderAgentMessage(m.content)}
|
| 98 |
+
</div>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
))}
|
| 102 |
+
{isWaiting && (
|
| 103 |
+
<div className="flex justify-start items-center space-x-2 text-muted mt-4">
|
| 104 |
+
<div className="w-8 h-8 rounded-full bg-accent text-background flex items-center justify-center font-bold mr-3 shrink-0">SF</div>
|
| 105 |
+
<div className="flex space-x-1">
|
| 106 |
+
<div className="w-2 h-2 bg-muted rounded-full animate-bounce"></div>
|
| 107 |
+
<div className="w-2 h-2 bg-muted rounded-full animate-bounce" style={{animationDelay: "0.2s"}}></div>
|
| 108 |
+
<div className="w-2 h-2 bg-muted rounded-full animate-bounce" style={{animationDelay: "0.4s"}}></div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
<div ref={endRef} />
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div className="p-4 border-t border-border bg-background">
|
| 116 |
+
<textarea
|
| 117 |
+
value={input}
|
| 118 |
+
onChange={(e) => setInput(e.target.value)}
|
| 119 |
+
onKeyDown={(e) => {
|
| 120 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 121 |
+
e.preventDefault()
|
| 122 |
+
handleSend()
|
| 123 |
+
}
|
| 124 |
+
}}
|
| 125 |
+
disabled={isWaiting}
|
| 126 |
+
placeholder="Type your answer..."
|
| 127 |
+
className="w-full bg-surface border border-border rounded-lg p-3 text-text focus:outline-none focus:border-accent resize-none disabled:opacity-50"
|
| 128 |
+
rows={3}
|
| 129 |
+
/>
|
| 130 |
+
<div className="flex justify-end mt-2">
|
| 131 |
+
<button
|
| 132 |
+
onClick={handleSend}
|
| 133 |
+
disabled={isWaiting || !input.trim()}
|
| 134 |
+
className="bg-accent text-background px-4 py-2 rounded font-mono text-sm font-bold disabled:opacity-50"
|
| 135 |
+
>
|
| 136 |
+
Send
|
| 137 |
+
</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
)
|
| 142 |
+
}
|