team99tech commited on
Commit
dfb3d07
·
1 Parent(s): 7e3263e

Added changes

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +53 -0
  2. README.md +99 -1
  3. backend/agents/__pycache__/challenger.cpython-312.pyc +0 -0
  4. backend/agents/__pycache__/extractor.cpython-312.pyc +0 -0
  5. backend/agents/__pycache__/interviewer.cpython-312.pyc +0 -0
  6. backend/agents/__pycache__/scorer.cpython-312.pyc +0 -0
  7. backend/agents/challenger.py +59 -0
  8. backend/agents/extractor.py +76 -0
  9. backend/agents/interviewer.py +55 -0
  10. backend/agents/scorer.py +62 -0
  11. backend/data/sample_jd_resume.json +4 -0
  12. backend/graph_pipeline/__pycache__/edges.cpython-312.pyc +0 -0
  13. backend/graph_pipeline/__pycache__/nodes.cpython-312.pyc +0 -0
  14. backend/graph_pipeline/__pycache__/pipeline.cpython-312.pyc +0 -0
  15. backend/graph_pipeline/__pycache__/state.cpython-312.pyc +0 -0
  16. backend/graph_pipeline/edges.py +28 -0
  17. backend/graph_pipeline/nodes.py +176 -0
  18. backend/graph_pipeline/pipeline.py +50 -0
  19. backend/graph_pipeline/state.py +30 -0
  20. backend/knowledge_graph/__pycache__/graph_engine.cpython-312.pyc +0 -0
  21. backend/knowledge_graph/graph_engine.py +120 -0
  22. backend/knowledge_graph/resources.json +62 -0
  23. backend/knowledge_graph/skills_graph.json +517 -0
  24. backend/main.py +27 -0
  25. backend/models/__pycache__/schemas.cpython-312.pyc +0 -0
  26. backend/models/schemas.py +91 -0
  27. backend/output/__pycache__/roadmap_generator.cpython-312.pyc +0 -0
  28. backend/output/roadmap_generator.py +89 -0
  29. backend/requirements.txt +12 -0
  30. backend/routers/__pycache__/assessment.cpython-312.pyc +0 -0
  31. backend/routers/__pycache__/roadmap.cpython-312.pyc +0 -0
  32. backend/routers/__pycache__/upload.cpython-312.pyc +0 -0
  33. backend/routers/assessment.py +99 -0
  34. backend/routers/roadmap.py +34 -0
  35. backend/routers/upload.py +24 -0
  36. backend/scoring/__pycache__/claim_vs_reality.cpython-312.pyc +0 -0
  37. backend/scoring/__pycache__/gap_classifier.cpython-312.pyc +0 -0
  38. backend/scoring/claim_vs_reality.py +26 -0
  39. backend/scoring/gap_classifier.py +7 -0
  40. frontend/app/assess/[id]/page.tsx +99 -0
  41. frontend/app/candidate/job/[id]/page.tsx +129 -0
  42. frontend/app/candidate/page.tsx +83 -0
  43. frontend/app/employer/job/[id]/page.tsx +118 -0
  44. frontend/app/employer/page.tsx +136 -0
  45. frontend/app/globals.css +22 -0
  46. frontend/app/layout.tsx +22 -0
  47. frontend/app/login/page.tsx +151 -0
  48. frontend/app/page.tsx +43 -0
  49. frontend/app/results/[id]/page.tsx +195 -0
  50. 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
+ }