A production-grade multi-agent AI system for ML/AI students. Finds internships, maps skill gaps, builds learning roadmaps, coaches interviews, and tracks applications, purpose-built for the Indian ML/AI job market.
Stack: FastAPI · LangGraph 0.2 · pgvector · Claude Sonnet 4.6 + Haiku 4.5 · sentence-transformers · Next.js 14 · TypeScript
POST /api/chat/stream
│
▼
FastAPI SSE endpoint (rate-limited: 10 req/user/min)
│
▼
build_graph(db) ← compiled per request, binds AsyncSession
│
┌────┴────┐
│ │ parallel superstep
▼ ▼
memory_ intent_ memory_retriever: hybrid pgvector search
retriever router intent_router: Haiku classifies one of 7 intents
│ │
└────┬────┘
▼
router_node fan-in join; fires conditional routing
│
┌────┼─────────────────────────────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
oppty_ resume_ gap_ roadmap_ app_tracker interview_
hunter analyzer detector planner coach
│ │ │ │ │
└────┴────────────┴───────────┴─────────────┘
│
responder ← Sonnet synthesis
│
memory_writer ← stores episode to pgvector
│
END
What happens on every request:
memory_retrieverandintent_routerrun in parallel — vector DB search overlaps with Haiku LLM call (~2–3s saved)- Intent is classified into one of 7 labels; the conditional edge routes to the correct agent
- The agent executes, updates shared
AgentState, and passes toresponder respondersynthesises a final response using all accumulated state- The response is streamed word-by-word to the client as SSE
tokenevents memory_writerpersists the interaction as a pgvector episode
Important constraint: build_graph(db) is called per request so each node gets the request-scoped AsyncSession — no shared state across concurrent requests.
| Node | File | What it actually does |
|---|---|---|
memory_retriever |
graph.py |
Hybrid pgvector query: importance × (0.7 × cosine + 0.3 × recency). Also retrieves relevant resume chunks. |
intent_router |
graph.py + llm_client.py |
Haiku classify_intent() → one of opportunities | resume | gaps | roadmap | track | interview | general. Optional: replaced by QLoRA fine-tuned Qwen2.5-3B when USE_LOCAL_CLASSIFIER=true. |
opportunity_hunter |
agents/opportunity_hunter.py |
Detects "latest/recent/new" keywords → triggers quick_fetch() (8-second live scrape). Then cosine search over embeddings. |
resume_analyzer |
agents/resume_analyzer.py |
Reads stored resume text, re-runs Sonnet extraction, normalises through skill taxonomy, recomputes profile embedding. |
gap_detector |
agents/gap_detector.py |
4-stage matching: taxonomy normalise → exact → substring → fuzzy (SequenceMatcher ≥ 0.82). Tier-weighted scoring (Python/PyTorch = 2×). Sonnet ranks missing skills by hours-to-learn. |
roadmap_planner |
agents/roadmap_planner.py |
Sonnet generates time-boxed roadmap with real resources (Coursera, GitHub, Kaggle). Persists to DB. |
application_tracker |
agents/application_tracker.py |
Fetches all applications. If message contains "applied to/for", uses Sonnet to extract details and creates a DB record. |
interview_coach |
agents/interview_coach.py |
Detects answering vs asking. Generates role-specific questions (2 technical, 1 system design, 1 project, 1 behavioural) or evaluates an answer on 3 dimensions. |
responder |
graph.py |
Builds contextual prompt from accumulated state, calls Sonnet, returns final response. |
memory_writer |
graph.py |
Stores structured episode with importance scaling based on interaction richness. |
The gap_detector uses a four-stage matching algorithm so "sklearn" matches "scikit-learn" and "pytorch" matches "PyTorch":
Required skill: "sklearn"
↓
1. Taxonomy normalisation: "sklearn" → "scikit-learn"
2. Exact match: is "scikit-learn" in candidate? → check
3. Substring match: is "scikit" in any candidate skill (bidirectional)?
4. Fuzzy match: SequenceMatcher ratio ≥ 0.82 against all candidates
Scoring is tier-weighted, not a naive percentage:
- Tier 1 (Python, PyTorch, SQL, Git, Deep Learning, etc.) → weight 2.0×
- Tier 2 (scikit-learn, FastAPI, Docker, RAG, Hugging Face, etc.) → weight 1.5×
- Tier 3 (everything else) → weight 1.0×
Episodic memory uses a custom hybrid scoring formula in PostgreSQL:
ORDER BY importance * (
0.7 * (1.0 - (embedding <=> query_vec)) -- semantic similarity
+ 0.3 * (1.0 / (1.0 + days_since_created)) -- recency decay
) DESCMemories with cosine distance ≥ 0.75 are filtered out. Importance scales with interaction richness (base 1.0, +0.5 for gap analysis, +0.5 for roadmap, +0.3 for opportunities).
Five data sources, all running concurrently:
| Source | Method | Notes |
|---|---|---|
| Internshala | HTTP scrape (BeautifulSoup) | 6 ML/AI category pages, 1.5s polite delay |
| Adzuna | REST API | Requires ADZUNA_APP_ID + ADZUNA_APP_KEY |
| Himalayas | REST API | Remote-friendly tech roles |
| Remotive | REST API | Remote ML/data roles |
| Unstop | REST API | India-focused hackathons + internships |
Scheduled refresh: 6:00 and 18:00 IST daily (APScheduler CronTrigger, 5-minute misfire grace).
On-demand live fetch: when a user asks for "latest/recent/new" internships in chat, opportunity_hunter triggers quick_fetch() — all 5 sources with a hard 8-second timeout, top 3 per source upserted before semantic search runs.
Upsert key: (source, external_id) — idempotent across refreshes. Manual/seeded listings (source IS NULL) are never pruned.
| Layer | Technology |
|---|---|
| Backend | FastAPI 0.110, Python 3.12 |
| Agent orchestration | LangGraph 0.2 |
| Primary LLM | Claude Sonnet 4.6 (claude-sonnet-4-6) |
| Routing LLM | Claude Haiku 4.5 (claude-haiku-4-5-20251001) |
| Intent routing (optional) | Qwen2.5-3B-Instruct, QLoRA fine-tuned |
| Embeddings | sentence-transformers (all-MiniLM-L6-v2, 384-dim) |
| Database | PostgreSQL + pgvector (Neon free tier) |
| ORM | SQLAlchemy 2.0 async + psycopg3 |
| PDF parsing | pdfplumber |
| ML fine-tuning | QLoRA via PEFT + TRL (Colab T4) |
| Frontend | Next.js 14, TypeScript, CSS Modules |
| Deployment | Render (backend) + Vercel (frontend) |
Create a free project at neon.tech. Copy the psycopg connection string — pgvector is pre-enabled on Neon.
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # fill in DATABASE_URL + ANTHROPIC_API_KEY
uvicorn app.main:app --reload --port 8000Tables and the pgvector extension are created automatically on startup.
Swagger UI: http://localhost:8000/docs
python -m db.seed_opportunitiesSeeds curated Indian ML/AI internship listings with pgvector embeddings.
cd frontend
npm install
npm run dev # http://localhost:3000Set NEXT_PUBLIC_API_URL=http://localhost:8000 or point to your deployed backend.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | — | psycopg3-format PostgreSQL URL (starts with postgresql:// or postgres://) |
ANTHROPIC_API_KEY |
Yes | — | Anthropic API key |
CLAUDE_MODEL |
No | claude-sonnet-4-6 |
Primary model for agent tasks |
FAST_MODEL |
No | claude-haiku-4-5-20251001 |
Fast model for intent routing |
EMBEDDING_MODEL |
No | all-MiniLM-L6-v2 |
sentence-transformers model name |
JWT_SECRET_KEY |
No | dev default | Change to a random 32-char string in production |
ADZUNA_APP_ID |
No | — | Adzuna developer credentials |
ADZUNA_APP_KEY |
No | — | Adzuna developer credentials |
USE_LOCAL_CLASSIFIER |
No | false |
true to use QLoRA Qwen2.5-3B for intent routing |
LOCAL_CLASSIFIER_PATH |
No | — | Absolute path to QLoRA adapter directory |
LANGSMITH_API_KEY |
No | — | LangSmith tracing |
LANGSMITH_TRACING |
No | false |
true to enable LangSmith traces |
POST /api/auth/register {name, email, password, goal?} → {access_token, user}
POST /api/auth/login {email, password} → {access_token, user}
GET /api/auth/me (Bearer token) → user
POST /api/users {name?, email?, goal?} → user (201)
GET /api/users/{id} → user
PATCH /api/users/{id} {name?, goal?, target_role?} → user
POST /api/profile/upload multipart: user_id=<id> + file=<PDF or .txt>
→ {skills, projects, experience_text, chunk_count}
GET /api/profile/{user_id} → {skills, projects, experience_text}
POST /api/chat {user_id, message} → {user_id, response, intent, data}
POST /api/chat/stream {user_id, message} → text/event-stream (SSE)
GET /api/chat/health → {"status": "ok"}
Rate limit on /api/chat/stream: 10 requests per user_id per 60 seconds. Exceeding returns HTTP 429.
SSE event stream from /api/chat/stream:
data: {"type": "progress", "node": "memory_retriever", "detail": "Recalled 3 memories"}
data: {"type": "progress", "node": "intent_router", "detail": "Searching for internships"}
data: {"type": "progress", "node": "opportunity_hunter","detail": "Found 6 matching internships"}
data: {"type": "data", "key": "opportunities", "value": [{...}, ...]}
data: {"type": "data", "key": "gap_score", "value": 74}
data: {"type": "progress", "node": "responder", "detail": "Writing response…"}
data: {"type": "token", "chunk": "Based "}
data: {"type": "token", "chunk": "on "}
...
data: {"type": "done"}
GET /api/opportunities ?type=internship&limit=20 → list
POST /api/opportunities/search {query, user_id?, limit?} → list (semantic)
POST /api/opportunities {title, company, required_skills, ...} → opportunity (201)
GET /api/tracker/{user_id} → list of applications
POST /api/tracker {user_id, company, role, status?, applied_date?, notes?, url?} (201)
PATCH /api/tracker/{app_id} {status?, notes?, deadline?} → application
DELETE /api/tracker/{app_id} (204)
Valid statuses: applied | interview | offered | rejected | withdrawn
GET /api/history/conversations/{user_id} → [{id, title, message_count, updated_at}]
POST /api/history/conversations {user_id, title?} → conversation (201)
GET /api/history/conversations/{conv_id}/messages → conversation with messages array
PATCH /api/history/conversations/{conv_id} {title} → conversation
DELETE /api/history/conversations/{conv_id} (204)
POST /api/history/messages {conversation_id, role, content} → message (201)
POST /api/admin/refresh-jobs
→ {fetched, upserted, pruned, source_counts: {internshala: N, ...}, errors: []}
GET /api/admin/scheduler-status
→ {running: true, jobs: [{id, name, next_run_time, trigger}]}
GET / → {name, version, agents: [...]}
GET /api/health → {status, db, opportunity_count, embedding_model}
HTTP 503 if DB is unreachable
Knowledge distillation: Claude (teacher) generates labeled training data; Qwen2.5-3B is fine-tuned via QLoRA to handle intent classification locally.
Claude Sonnet (teacher)
│ generates 500 labeled career queries across 7 intents
▼
ml/data/training_pairs.jsonl
│ QLoRA fine-tuning — Colab T4 GPU, ~25 min
│ 4-bit NF4 quantisation, rank-16 LoRA, Paged AdamW 8-bit
▼
ml/checkpoints/intent-classifier/final/ ← LoRA adapter (~30 MB)
│
▼
distill_intent.classify_intent(message) ← loaded lazily, runs in thread pool
Activate:
# backend/.env
USE_LOCAL_CLASSIFIER=true
LOCAL_CLASSIFIER_PATH=/absolute/path/to/intent-classifier/finalGenerate training data:
python ml/generate_synthetic_data.pyFine-tune (run in Colab):
!pip install -q "transformers>=4.45" "peft>=0.13" "trl>=0.12" \
"bitsandbytes>=0.43" "datasets>=2.19" "accelerate>=0.34"
# upload training_pairs.jsonl, then run ml/train_skill_gap_classifier.pyStandalone inference:
python ml/distill_intent.py "Find me ML internships in Bangalore"
# → [opportunities] Find me ML internships in BangaloreWhy it matters: eliminates one API call per message, runs at ~0.05s vs ~0.3s for Haiku. Not feasible on Render free tier (3B model weights need ~6 GB RAM) — suitable for GPU server or local dev.
mitra/
├── backend/
│ ├── app/
│ │ ├── main.py FastAPI + lifespan (init_db, scheduler, embedding pre-warm)
│ │ ├── config.py pydantic-settings (.env)
│ │ ├── database.py async SQLAlchemy engine + session factory
│ │ ├── scheduler.py APScheduler (6:00 and 18:00 IST)
│ │ ├── agents/
│ │ │ ├── state.py AgentState TypedDict
│ │ │ ├── graph.py LangGraph StateGraph — all wiring + responder
│ │ │ ├── opportunity_hunter.py live fetch + cosine search
│ │ │ ├── resume_analyzer.py skill extraction + profile embedding
│ │ │ ├── gap_detector.py 4-stage matching + tier-weighted scoring
│ │ │ ├── roadmap_planner.py time-boxed plan with real resources
│ │ │ ├── application_tracker.py NL extraction + CRUD
│ │ │ └── interview_coach.py Q-generation + answer evaluation
│ │ ├── routers/
│ │ │ ├── chat.py SSE streaming + rate limiter
│ │ │ ├── admin.py refresh-jobs + scheduler-status
│ │ │ ├── auth.py JWT register / login / me
│ │ │ ├── profile.py PDF upload + chunking
│ │ │ ├── opportunities.py list + semantic search + add
│ │ │ ├── tracker.py application CRUD
│ │ │ ├── history.py conversation + message CRUD
│ │ │ └── users.py user CRUD
│ │ ├── services/
│ │ │ ├── llm_client.py Anthropic SDK — Sonnet + Haiku tiers
│ │ │ ├── embedding_service.py sentence-transformers singleton
│ │ │ ├── memory_service.py hybrid-scored pgvector episodic memory
│ │ │ ├── skill_graph.py taxonomy + 4-stage matching + scoring
│ │ │ ├── resume_service.py chunk store + cosine retrieval
│ │ │ ├── resume_chunker.py section-aware + size-split chunking
│ │ │ └── job_fetcher/
│ │ │ ├── __init__.py run() + quick_fetch() + ingest_jobs()
│ │ │ ├── normalizer.py FetchedJob dataclass + embed_text
│ │ │ ├── adzuna.py
│ │ │ ├── internshala.py BeautifulSoup scraper
│ │ │ ├── himalayas.py
│ │ │ ├── remotive.py
│ │ │ └── unstop.py
│ │ └── models/
│ │ ├── db.py 9 SQLAlchemy ORM tables
│ │ └── schemas.py Pydantic request/response schemas
│ ├── db/
│ │ └── seed_opportunities.py curated ML/AI listings with embeddings
│ └── requirements.txt
├── frontend/
│ ├── src/app/
│ │ ├── page.tsx Landing (bento grid, stats bar)
│ │ ├── chat/ SSE chat + sidebar conversation history
│ │ ├── opportunities/ Semantic internship search
│ │ ├── tracker/ Kanban application pipeline (5 columns)
│ │ ├── profile/ Skill radar + resume upload
│ │ ├── onboarding/ PDF dropzone + processing stages
│ │ ├── auth/ Sign in / Register
│ │ ├── about/ Project overview + agent docs
│ │ └── components/ Nav.tsx, Footer.tsx
│ └── src/lib/ API client, auth helpers, types
├── ml/
│ ├── generate_synthetic_data.py 500-pair training set generation
│ ├── train_skill_gap_classifier.py QLoRA fine-tuning (Colab T4)
│ ├── train_kaggle.py Unsloth variant (Kaggle T4×2)
│ └── distill_intent.py Adapter loader + inference
├── COVERAGE.md Detailed technical coverage (read this)
├── SRS.md Software Requirements Specification
├── CLAUDE.md AI assistant context file
└── README.md This file
- Create a new Web Service from the
backend/directory. - Set environment: Python 3.12, build command
pip install -r requirements.txt, start commanduvicorn app.main:app --host 0.0.0.0 --port $PORT. - Add all required environment variables in Render's dashboard.
Free tier note: the sentence-transformers model (all-MiniLM-L6-v2, ~90 MB) loads at startup. Render free tier has 512 MB RAM — the model fits, but the first request after a cold start still pays a ~5-second embedding load cost. The fast_complete() Haiku path eliminates the intent-routing API latency.
cd frontend
npx vercel --prodSet NEXT_PUBLIC_API_URL to your Render backend URL in Vercel environment variables.
- Multi-agent LangGraph orchestration with parallel entry nodes
- Hybrid-scored pgvector episodic memory (semantic + recency + importance)
- Resume upload → PDF parsing → section-aware RAG chunking
- 4-stage skill matching with tier-weighted scoring and skill taxonomy
- Semantic opportunity search + live on-demand fetch for "latest" queries
- Application tracker with natural-language create-from-chat
- Interview coach: question generation + answer evaluation
- ML fine-tuning pipeline (QLoRA knowledge distillation from Claude)
- Dual-model tier: Haiku for routing, Sonnet for agent tasks
- JWT auth + bcrypt, rate limiting, health check, scheduler status API
- Next.js 14 frontend: SSE chat, kanban tracker, skill radar, bento landing
- True token streaming in responder (currently word-split after full generation)
- Multi-intent parsing (single intent per request today)
- Conversation history injection into agent state
- Explainable opportunity matching (per-listing match score + skill breakdown)
- Resume diff on re-upload (skill progress tracking)
- Duplicate memory suppression
- Roadmap → GapAnalysis DB linkage (foreign key exists, not yet populated)
- LangSmith observability