Spaces:
Sleeping
Sleeping
Recruitment Copilot
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +58 -0
- Dockerfile +34 -0
- LICENSE +21 -0
- README.md +178 -3
- backend/Dockerfile +17 -0
- backend/agent/__init__.py +1 -0
- backend/agent/agent.py +114 -0
- backend/agent/runtime.py +225 -0
- backend/core/__init__.py +1 -0
- backend/core/config.py +110 -0
- backend/core/db.py +34 -0
- backend/core/models.py +91 -0
- backend/core/schemas.py +42 -0
- backend/core/utils.py +266 -0
- backend/data/policies.json +7 -0
- backend/data/seed_candidates.py +69 -0
- backend/main.py +439 -0
- backend/mcp_server/__init__.py +1 -0
- backend/mcp_server/router.py +43 -0
- backend/mcp_server/server.py +74 -0
- backend/mcp_server/tools/__init__.py +1 -0
- backend/mcp_server/tools/application_status.py +155 -0
- backend/mcp_server/tools/candidate_query.py +31 -0
- backend/mcp_server/tools/email_compose.py +243 -0
- backend/mcp_server/tools/interview_records.py +394 -0
- backend/mcp_server/tools/job_matching.py +58 -0
- backend/mcp_server/tools/job_posting.py +202 -0
- backend/mcp_server/tools/pdf_ingest.py +40 -0
- backend/mcp_server/tools/policy_info.py +195 -0
- backend/mcp_server/tools/reference_resumes.py +45 -0
- backend/mcp_server/tools/vector_search.py +27 -0
- backend/requirements.txt +14 -0
- backend/scripts/load_reference_resumes.py +53 -0
- backend/scripts/sync_reference_resumes.py +44 -0
- backend/services/__init__.py +1 -0
- backend/services/candidate_service.py +414 -0
- backend/services/embedding_service.py +43 -0
- backend/services/ingestion_service.py +270 -0
- backend/services/notification_service.py +248 -0
- backend/services/reference_data_service.py +164 -0
- backend/services/search_service.py +162 -0
- backend/services/storage_service.py +78 -0
- frontend/.env.example +1 -0
- frontend/next-env.d.ts +5 -0
- frontend/next.config.js +11 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +34 -0
- frontend/src/app/api/candidates/[id]/route.ts +22 -0
- frontend/src/app/api/chat/route.ts +33 -0
- frontend/src/app/api/upload/route.ts +27 -0
.gitignore
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Recruitment Copilot — git ignore
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# --- Secrets -----------------------------------------------------------------
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
!.env.example
|
| 9 |
+
frontend/.env.local
|
| 10 |
+
backend/.env
|
| 11 |
+
|
| 12 |
+
# --- Python ------------------------------------------------------------------
|
| 13 |
+
__pycache__/
|
| 14 |
+
*.pyc
|
| 15 |
+
*.pyo
|
| 16 |
+
*.pyd
|
| 17 |
+
*.egg-info/
|
| 18 |
+
.pytest_cache/
|
| 19 |
+
.mypy_cache/
|
| 20 |
+
.ruff_cache/
|
| 21 |
+
.venv/
|
| 22 |
+
venv/
|
| 23 |
+
env/
|
| 24 |
+
|
| 25 |
+
# --- Node / Next.js ----------------------------------------------------------
|
| 26 |
+
node_modules/
|
| 27 |
+
.next/
|
| 28 |
+
out/
|
| 29 |
+
.turbo/
|
| 30 |
+
.swc/
|
| 31 |
+
npm-debug.log*
|
| 32 |
+
yarn-debug.log*
|
| 33 |
+
yarn-error.log*
|
| 34 |
+
|
| 35 |
+
# --- Local data (regenerated at runtime; do not commit user data) -----------
|
| 36 |
+
backend/data/uploads/
|
| 37 |
+
backend/data/candidates.db
|
| 38 |
+
backend/data/*.sqlite*
|
| 39 |
+
backend/data/job_postings.json
|
| 40 |
+
backend/data/reference_resumes/
|
| 41 |
+
|
| 42 |
+
# --- Build / runtime artefacts ----------------------------------------------
|
| 43 |
+
logs/
|
| 44 |
+
.local-dev-processes.json
|
| 45 |
+
*.log
|
| 46 |
+
adk-events.log
|
| 47 |
+
|
| 48 |
+
# --- IDE / OS ----------------------------------------------------------------
|
| 49 |
+
.vscode/
|
| 50 |
+
.idea/
|
| 51 |
+
.DS_Store
|
| 52 |
+
Thumbs.db
|
| 53 |
+
*.swp
|
| 54 |
+
|
| 55 |
+
# --- Internal design docs (not for the public repo) -------------------------
|
| 56 |
+
GenUi_Recruitement_Agent.docx
|
| 57 |
+
GenUi_Recruitement_Agent.pdf
|
| 58 |
+
__req_copy.docx
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space (Docker SDK) — backend only.
|
| 2 |
+
# The frontend runs separately on Netlify (or Vercel). Netlify only needs
|
| 3 |
+
# BACKEND_URL pointing at this Space's public URL.
|
| 4 |
+
|
| 5 |
+
FROM python:3.11-slim
|
| 6 |
+
|
| 7 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 8 |
+
PYTHONUNBUFFERED=1 \
|
| 9 |
+
PIP_NO_CACHE_DIR=1 \
|
| 10 |
+
HF_HOME=/home/user/.cache/huggingface
|
| 11 |
+
|
| 12 |
+
# HF Spaces runs containers as UID 1000. Create that user up-front so writes to
|
| 13 |
+
# uploads/data/logs work without root.
|
| 14 |
+
RUN useradd --create-home --uid 1000 user
|
| 15 |
+
|
| 16 |
+
WORKDIR /home/user/app
|
| 17 |
+
|
| 18 |
+
COPY --chown=user:user backend/requirements.txt requirements.txt
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
COPY --chown=user:user backend ./backend
|
| 22 |
+
|
| 23 |
+
# Pre-create the writable directories the app expects. On the free Space tier
|
| 24 |
+
# these are ephemeral (wiped on rebuild/sleep) — point S3_* at Supabase Storage
|
| 25 |
+
# in your Space secrets to persist resume PDFs across restarts.
|
| 26 |
+
RUN mkdir -p backend/data/uploads backend/data/reference_resumes logs && \
|
| 27 |
+
chown -R user:user backend/data logs
|
| 28 |
+
|
| 29 |
+
USER user
|
| 30 |
+
WORKDIR /home/user/app/backend
|
| 31 |
+
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Ashgen12
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,185 @@
|
|
| 1 |
---
|
| 2 |
title: Recruitment Copilot
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: green
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Recruitment Copilot
|
| 3 |
+
emoji: 🎯
|
| 4 |
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Agentic recruitment copilot for HR teams.
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Recruitment Copilot
|
| 14 |
+
|
| 15 |
+
An end-to-end agentic recruitment workspace built on **Google ADK** + **FastMCP**, with a Next.js chat UI rendered through the free **Crayon UI** components from the Thesys SDK.
|
| 16 |
+
|
| 17 |
+
It lets a recruiter:
|
| 18 |
+
|
| 19 |
+
- 🔍 Find candidates by natural-language queries (`"give me top AI engineers"`, `"anyone with NLP background"`).
|
| 20 |
+
- 📄 Ingest scanned or text PDFs — Gemini OCR is used as a fallback when `pypdf` returns nothing.
|
| 21 |
+
- 📋 Pull all company HR policies in one click, rendered as a tabbed infographic with a donut chart.
|
| 22 |
+
- 📝 Draft a structured job posting from a chat brief — full markdown post + skill-weighting bar chart.
|
| 23 |
+
- 📅 Schedule interviews — auto-generates a Google Meet link, builds an `.ics`, and emails both attendees via SMTP.
|
| 24 |
+
- ✉️ Compose follow-up emails: the agent returns three tonal drafts (formal / casual / polite); pick one, give a recipient, and the email goes out via SMTP.
|
| 25 |
+
|
| 26 |
+
## Architecture
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
┌──────────────┐ ┌────────────────────┐ ┌─────────────────┐
|
| 30 |
+
│ Netlify │───▶│ HF Spaces (Docker)│───▶│ Gemini API │
|
| 31 |
+
│ Next.js │ │ FastAPI + ADK + │ │ (LLM + OCR) │
|
| 32 |
+
│ frontend │ │ MCP server │ └─────────────────┘
|
| 33 |
+
└──────────────┘ └────────────────────┘ ┌─────────────────┐
|
| 34 |
+
├─────────────▶│ Qdrant Cloud │
|
| 35 |
+
│ │ (vector index) │
|
| 36 |
+
│ └─────────────────┘
|
| 37 |
+
│ ┌─────────────────┐
|
| 38 |
+
├─────────────▶│ Supabase │
|
| 39 |
+
│ │ Storage (PDFs) │
|
| 40 |
+
│ └─────────────────┘
|
| 41 |
+
│ ┌─────────────────┐
|
| 42 |
+
└─────────────▶│ Gmail SMTP │
|
| 43 |
+
│ (app password) │
|
| 44 |
+
└─────────────────┘
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
### Backend
|
| 48 |
+
- **FastAPI** serves the chat SSE stream and proxies tool calls.
|
| 49 |
+
- **Google ADK** agent (`gemini-2.5-pro`) orchestrates 10 MCP tools.
|
| 50 |
+
- **FastMCP** exposes those tools over `/mcp-server/sse`:
|
| 51 |
+
|
| 52 |
+
| Tool | Purpose |
|
| 53 |
+
|---|---|
|
| 54 |
+
| `ingest_resume_pdf` | Parse a PDF (pypdf → Gemini OCR fallback) and persist to SQLite + Qdrant |
|
| 55 |
+
| `semantic_candidate_search` | Vector search via Qdrant Cloud |
|
| 56 |
+
| `candidate_metadata_query` | Keyword search over local SQLite, role-aware filtering |
|
| 57 |
+
| `compute_job_match_score` | Score a single candidate against a JD |
|
| 58 |
+
| `get_policy_info` | Return company HR policies (donut + tabs UI) |
|
| 59 |
+
| `manage_application_status` | Track candidate stages |
|
| 60 |
+
| `manage_interview_records` | Schedule interviews + auto-email Google Meet invite |
|
| 61 |
+
| `generate_job_posting` | Draft a job posting from natural-language fields |
|
| 62 |
+
| `bulk_ingest_reference_resumes` | Bootstrap demo data |
|
| 63 |
+
| `email_compose` | Draft 3 tone variants → send via SMTP |
|
| 64 |
+
|
| 65 |
+
### Frontend
|
| 66 |
+
- **Next.js 14** (App Router) with a streaming chat UI.
|
| 67 |
+
- **Crayon UI** (`@crayonai/react-ui`) for cards, callouts, charts (bar/pie/donut/radar), tabs, tags, buttons.
|
| 68 |
+
- A small **chart auto-picker** chooses bar / donut / radar based on the shape of the data the backend returns, so visualisations adapt to the response.
|
| 69 |
+
|
| 70 |
+
## Local development
|
| 71 |
+
|
| 72 |
+
### Prerequisites
|
| 73 |
+
- Python 3.11+
|
| 74 |
+
- Node.js 18+
|
| 75 |
+
- A Gemini API key — https://aistudio.google.com/apikey
|
| 76 |
+
|
| 77 |
+
### One-shot bring-up (Windows / PowerShell)
|
| 78 |
+
```powershell
|
| 79 |
+
copy .env.example .env # then fill in GEMINI_API_KEY at minimum
|
| 80 |
+
.\start-all.ps1
|
| 81 |
+
```
|
| 82 |
+
This installs dependencies, starts FastAPI on `http://127.0.0.1:7860` and Next.js on `http://127.0.0.1:3000`. Use `.\stop-all.ps1` to shut both down.
|
| 83 |
+
|
| 84 |
+
### Manual
|
| 85 |
+
```bash
|
| 86 |
+
# backend
|
| 87 |
+
cd backend
|
| 88 |
+
pip install -r requirements.txt
|
| 89 |
+
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 90 |
+
|
| 91 |
+
# frontend (in another shell)
|
| 92 |
+
cd frontend
|
| 93 |
+
npm install
|
| 94 |
+
npm run dev
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Configuration
|
| 98 |
+
|
| 99 |
+
All runtime config is read from `.env`. See [`.env.example`](.env.example) for the full list with inline links to where each credential is generated.
|
| 100 |
+
|
| 101 |
+
| Group | Vars | Required for |
|
| 102 |
+
|---|---|---|
|
| 103 |
+
| Gemini | `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_EMBEDDING_MODEL` | LLM, embeddings, scanned-PDF OCR |
|
| 104 |
+
| Qdrant | `QDRANT_URL`, `QDRANT_API_KEY`, `QDRANT_COLLECTION` | Semantic candidate search |
|
| 105 |
+
| Object storage | `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET`, `S3_REGION`, optional `S3_PUBLIC_BASE_URL` | Persistent resume PDF storage (Supabase Storage / Backblaze / S3 / MinIO) |
|
| 106 |
+
| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `SMTP_USE_TLS` | Auto-email interview invites + composed emails |
|
| 107 |
+
| Misc | `BACKEND_URL`, `CALENDLY_EVENT_URL`, `THESYS_API_KEY` | Frontend → backend wiring (Netlify), optional integrations |
|
| 108 |
+
|
| 109 |
+
## Deployment (free tier)
|
| 110 |
+
|
| 111 |
+
```
|
| 112 |
+
GitHub repo → Netlify (frontend) ──▶ HF Space Docker (backend)
|
| 113 |
+
├──▶ Qdrant Cloud
|
| 114 |
+
├──▶ Supabase Storage
|
| 115 |
+
└──▶ Gmail SMTP
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### 1. Backend on Hugging Face Spaces (Docker)
|
| 119 |
+
1. Create a new Space → choose **Docker → Blank**.
|
| 120 |
+
2. The repo's root [`Dockerfile`](Dockerfile) is picked up automatically.
|
| 121 |
+
3. In **Settings → Variables and secrets**, add every key from `.env.example` (Gemini / Qdrant / S3 / SMTP). Set `CORS_ORIGINS=https://<your-netlify-site>.netlify.app`.
|
| 122 |
+
4. `git push` this repo to the Space remote — HF builds and exposes the API at `https://<user>-recruitment-copilot.hf.space`.
|
| 123 |
+
|
| 124 |
+
### 2. Frontend on Netlify
|
| 125 |
+
1. Import this repo in Netlify.
|
| 126 |
+
2. Set **Base directory** to `frontend`.
|
| 127 |
+
3. Add env var `BACKEND_URL=https://<user>-recruitment-copilot.hf.space`.
|
| 128 |
+
4. Deploy.
|
| 129 |
+
|
| 130 |
+
### 3. External services (all free, no card)
|
| 131 |
+
- **Qdrant Cloud** — https://cloud.qdrant.io → 1 GB free cluster.
|
| 132 |
+
- **Supabase Storage** — https://supabase.com → Free project → create private `resumes` bucket → Settings → Storage → S3 access keys.
|
| 133 |
+
- **Gemini API** — https://aistudio.google.com/apikey.
|
| 134 |
+
- **Gmail SMTP** — https://myaccount.google.com/apppasswords (requires 2-step verification).
|
| 135 |
+
|
| 136 |
+
## API surface
|
| 137 |
+
|
| 138 |
+
| Endpoint | Purpose |
|
| 139 |
+
|---|---|
|
| 140 |
+
| `GET /health` | Backend liveness |
|
| 141 |
+
| `GET /mcp-health` | List of registered MCP tools |
|
| 142 |
+
| `GET /api/tooling/status` | Reports which optional integrations are configured |
|
| 143 |
+
| `POST /api/upload?session_id=…` | Multipart PDF resume upload |
|
| 144 |
+
| `POST /api/chat` | SSE stream — chat with the agent |
|
| 145 |
+
| `GET /api/candidates/{external_id}` | Full candidate profile (markdown + card + chart) |
|
| 146 |
+
| `POST /api/bootstrap/sync-reference-resumes` | Copy sample PDFs into the project storage dir |
|
| 147 |
+
| `POST /api/bootstrap/reference-resumes` | Bulk-ingest the synced PDFs |
|
| 148 |
+
| `GET /mcp-server/sse` | MCP SSE stream endpoint |
|
| 149 |
+
| `POST /mcp-server/messages/?session_id=…` | MCP SSE message channel |
|
| 150 |
+
|
| 151 |
+
### SSE event contract (`/api/chat`)
|
| 152 |
+
| Event | Payload |
|
| 153 |
+
|---|---|
|
| 154 |
+
| `status` | `{ message }` |
|
| 155 |
+
| `token` | `{ delta }` (incremental text) |
|
| 156 |
+
| `genui` | `{ summary, markdown?, cards?, chart?, table?, tabs? }` |
|
| 157 |
+
| `done` | `{ message }` (final text) |
|
| 158 |
+
| `error` | `{ message }` |
|
| 159 |
+
|
| 160 |
+
## Project layout
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
.
|
| 164 |
+
├── backend/
|
| 165 |
+
│ ├── agent/ # Google ADK runtime + agent definition
|
| 166 |
+
│ ├── core/ # Settings, DB session, models, schemas, utils
|
| 167 |
+
│ ├── mcp_server/ # FastMCP server + 10 tool modules
|
| 168 |
+
│ │ └── tools/
|
| 169 |
+
│ ├── services/ # Candidate / search / ingestion / storage / notifications
|
| 170 |
+
│ ├── scripts/ # Reference-resume sync utilities
|
| 171 |
+
│ └── main.py # FastAPI app + chat SSE stream
|
| 172 |
+
├── frontend/
|
| 173 |
+
│ └── src/
|
| 174 |
+
│ ├── app/api/ # Next.js API proxy routes (chat, upload, candidates)
|
| 175 |
+
│ └── components/ # Chat UI + Crayon-powered renderer
|
| 176 |
+
├── Dockerfile # HF Spaces backend image
|
| 177 |
+
├── start-all.ps1 / stop-all.ps1
|
| 178 |
+
├── .env.example
|
| 179 |
+
├── LICENSE
|
| 180 |
+
└── README.md
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
## License
|
| 184 |
+
|
| 185 |
+
MIT — see [LICENSE](LICENSE).
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY backend/requirements.txt /app/requirements.txt
|
| 9 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY backend /app/backend
|
| 12 |
+
|
| 13 |
+
WORKDIR /app/backend
|
| 14 |
+
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
backend/agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Google ADK root agent and runtime adapters."""
|
backend/agent/agent.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from google.adk.agents import LlmAgent
|
| 6 |
+
from google.adk.tools.mcp_tool.mcp_toolset import (
|
| 7 |
+
MCPToolset,
|
| 8 |
+
SseConnectionParams,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
from core.config import get_settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_root_agent() -> LlmAgent:
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
|
| 17 |
+
if settings.gemini_api_key and not os.environ.get("GOOGLE_API_KEY"):
|
| 18 |
+
os.environ["GOOGLE_API_KEY"] = settings.gemini_api_key
|
| 19 |
+
|
| 20 |
+
instruction = """
|
| 21 |
+
You are a senior Recruitment Copilot built on Google ADK. You MUST drive every recruiter
|
| 22 |
+
intent through MCP tool calls — never answer from your own knowledge when a tool exists.
|
| 23 |
+
|
| 24 |
+
Available MCP tools and when to call them:
|
| 25 |
+
|
| 26 |
+
1) get_policy_info(policy_name)
|
| 27 |
+
- ALWAYS call this when the user asks anything about company policies: leave, vacation,
|
| 28 |
+
work-from-home, hybrid/remote, referral, interview, code of conduct, conduct, ethics.
|
| 29 |
+
- For generic asks ("check company policies", "show me all policies"), pass
|
| 30 |
+
policy_name="all" so the tool returns every policy.
|
| 31 |
+
|
| 32 |
+
2) generate_job_posting(title, location, experience_years, skills, employment_type, mode)
|
| 33 |
+
- Call as soon as you have enough fields. If the user provides a multi-line block with
|
| 34 |
+
"Job Title:", "Location:", "Required Skills:", "Experience Required:", parse those
|
| 35 |
+
directly and CALL THE TOOL on the next turn — do NOT ask for re-confirmation.
|
| 36 |
+
- skills must be a comma-separated string. experience_years is a single integer
|
| 37 |
+
(lower bound is fine if the user gives a range like "5-8").
|
| 38 |
+
|
| 39 |
+
3) manage_interview_records(mode, ...)
|
| 40 |
+
- Call with mode="schedule" when the user gives candidate_name, candidate_email,
|
| 41 |
+
interviewer_name, interviewer_email, date (YYYY-MM-DD), and time. Parse the structured
|
| 42 |
+
block the user gives — do NOT ask again for fields already provided.
|
| 43 |
+
- Use mode="list" to show upcoming interviews.
|
| 44 |
+
|
| 45 |
+
4) manage_application_status(mode, ...)
|
| 46 |
+
- Call with mode="upsert", "advance", "get", or "list" for application stage updates.
|
| 47 |
+
|
| 48 |
+
5) semantic_candidate_search(query, top_k)
|
| 49 |
+
- Call when the user asks to FIND, SEARCH, RANK, or SHORTLIST candidates by skills,
|
| 50 |
+
role, or location.
|
| 51 |
+
|
| 52 |
+
6) candidate_metadata_query(query, limit)
|
| 53 |
+
- Use as a fallback when semantic_candidate_search returns nothing, or for exact metadata.
|
| 54 |
+
|
| 55 |
+
7) compute_job_match_score(candidate_summary, job_description)
|
| 56 |
+
- Use when a single candidate must be scored against a JD.
|
| 57 |
+
|
| 58 |
+
8) ingest_resume_pdf(file_path)
|
| 59 |
+
- Use when the user attaches a resume.
|
| 60 |
+
|
| 61 |
+
9) bulk_ingest_reference_resumes(limit)
|
| 62 |
+
- Use only if the candidate database appears empty.
|
| 63 |
+
|
| 64 |
+
10) email_compose(mode, ...)
|
| 65 |
+
- When the user says "draft an email", "compose email", "write a follow-up", or
|
| 66 |
+
uses the Draft Email quick action, call email_compose(mode="draft", brief=<the
|
| 67 |
+
natural-language brief>, recipient_name=<optional>) — the tool returns three
|
| 68 |
+
tone variants (formal / casual / polite).
|
| 69 |
+
- Show all three tones to the user. When they pick one ("use formal", "send the
|
| 70 |
+
casual one", etc.) and provide an email address, call email_compose(
|
| 71 |
+
mode="send", recipient_email=<address>, subject=<from chosen draft>,
|
| 72 |
+
body=<from chosen draft>, tone=<chosen tone>) to actually deliver it.
|
| 73 |
+
|
| 74 |
+
Conversation rules:
|
| 75 |
+
- When a quick action like "Check company policies" arrives, immediately call
|
| 76 |
+
get_policy_info(policy_name="all") instead of asking the user which policy.
|
| 77 |
+
- When a quick action like "Create job posting" arrives without details, ask once for the
|
| 78 |
+
required fields. As soon as the next user turn provides them, CALL generate_job_posting.
|
| 79 |
+
- When a quick action like "Schedule interview" arrives without details, ask once for the
|
| 80 |
+
required fields. As soon as the next user turn provides them, CALL manage_interview_records.
|
| 81 |
+
- After every successful tool call, write a short recruiter-facing markdown summary of the
|
| 82 |
+
result. Do NOT include any candidate-search JSON unless you actually called a search tool.
|
| 83 |
+
- If a tool returns status "error" or "not_found", tell the user exactly what's missing.
|
| 84 |
+
- Do not invent candidate facts that did not come from a tool result.
|
| 85 |
+
""".strip()
|
| 86 |
+
|
| 87 |
+
return LlmAgent(
|
| 88 |
+
name="Recruitment_Root_Agent",
|
| 89 |
+
description="Root recruitment orchestrator using MCP tools",
|
| 90 |
+
model=settings.gemini_model,
|
| 91 |
+
instruction=instruction,
|
| 92 |
+
tools=[
|
| 93 |
+
MCPToolset(
|
| 94 |
+
connection_params=SseConnectionParams(
|
| 95 |
+
url=settings.mcp_server_base_url,
|
| 96 |
+
),
|
| 97 |
+
tool_filter=[
|
| 98 |
+
"ingest_resume_pdf",
|
| 99 |
+
"semantic_candidate_search",
|
| 100 |
+
"candidate_metadata_query",
|
| 101 |
+
"compute_job_match_score",
|
| 102 |
+
"get_policy_info",
|
| 103 |
+
"manage_application_status",
|
| 104 |
+
"manage_interview_records",
|
| 105 |
+
"generate_job_posting",
|
| 106 |
+
"bulk_ingest_reference_resumes",
|
| 107 |
+
"email_compose",
|
| 108 |
+
],
|
| 109 |
+
)
|
| 110 |
+
],
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
root_agent = create_root_agent()
|
backend/agent/runtime.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from google.adk.runners import InMemoryRunner
|
| 8 |
+
from google.genai import types
|
| 9 |
+
|
| 10 |
+
from agent.agent import root_agent
|
| 11 |
+
from core.config import get_settings
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class SessionState:
|
| 18 |
+
runner: InMemoryRunner
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class TurnResult:
|
| 23 |
+
text: str = ""
|
| 24 |
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ADKRuntime:
|
| 28 |
+
def __init__(self) -> None:
|
| 29 |
+
self.settings = get_settings()
|
| 30 |
+
self._sessions: dict[tuple[str, str], SessionState] = {}
|
| 31 |
+
|
| 32 |
+
async def run_turn(self, message: str, user_id: str, session_id: str) -> str:
|
| 33 |
+
result = await self.run_turn_detailed(
|
| 34 |
+
message=message,
|
| 35 |
+
user_id=user_id,
|
| 36 |
+
session_id=session_id,
|
| 37 |
+
)
|
| 38 |
+
return result.text
|
| 39 |
+
|
| 40 |
+
async def run_turn_detailed(
|
| 41 |
+
self,
|
| 42 |
+
message: str,
|
| 43 |
+
user_id: str,
|
| 44 |
+
session_id: str,
|
| 45 |
+
) -> TurnResult:
|
| 46 |
+
runner = await self._get_or_create_runner(user_id=user_id, session_id=session_id)
|
| 47 |
+
|
| 48 |
+
user_content = types.Content(
|
| 49 |
+
role="user",
|
| 50 |
+
parts=[types.Part.from_text(text=message)],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
latest_text = ""
|
| 54 |
+
tool_calls: list[dict[str, Any]] = []
|
| 55 |
+
async for event in runner.run_async(
|
| 56 |
+
user_id=user_id,
|
| 57 |
+
session_id=session_id,
|
| 58 |
+
new_message=user_content,
|
| 59 |
+
):
|
| 60 |
+
extracted = self._extract_text(event)
|
| 61 |
+
if extracted.strip():
|
| 62 |
+
latest_text = extracted
|
| 63 |
+
|
| 64 |
+
new_tool_responses = self._extract_tool_responses(event)
|
| 65 |
+
if new_tool_responses:
|
| 66 |
+
tool_calls.extend(new_tool_responses)
|
| 67 |
+
|
| 68 |
+
return TurnResult(text=latest_text.strip(), tool_calls=tool_calls)
|
| 69 |
+
|
| 70 |
+
async def _get_or_create_runner(self, user_id: str, session_id: str) -> InMemoryRunner:
|
| 71 |
+
key = (user_id, session_id)
|
| 72 |
+
existing = self._sessions.get(key)
|
| 73 |
+
if existing is not None:
|
| 74 |
+
return existing.runner
|
| 75 |
+
|
| 76 |
+
runner = InMemoryRunner(agent=root_agent, app_name=self.settings.app_name)
|
| 77 |
+
await runner.session_service.create_session(
|
| 78 |
+
app_name=self.settings.app_name,
|
| 79 |
+
user_id=user_id,
|
| 80 |
+
session_id=session_id,
|
| 81 |
+
)
|
| 82 |
+
self._sessions[key] = SessionState(runner=runner)
|
| 83 |
+
return runner
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def _extract_text(event: Any) -> str:
|
| 87 |
+
content = getattr(event, "content", None)
|
| 88 |
+
if content is None:
|
| 89 |
+
return ""
|
| 90 |
+
|
| 91 |
+
parts = getattr(content, "parts", None)
|
| 92 |
+
if not parts:
|
| 93 |
+
return ""
|
| 94 |
+
|
| 95 |
+
chunks: list[str] = []
|
| 96 |
+
for part in parts:
|
| 97 |
+
text = getattr(part, "text", None)
|
| 98 |
+
if isinstance(text, str) and text:
|
| 99 |
+
chunks.append(text)
|
| 100 |
+
|
| 101 |
+
return "\n".join(chunks)
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _debug_dump_event(event: Any) -> None:
|
| 105 |
+
try:
|
| 106 |
+
import json
|
| 107 |
+
from pathlib import Path
|
| 108 |
+
payload: dict[str, Any] = {"type": type(event).__name__}
|
| 109 |
+
content = getattr(event, "content", None)
|
| 110 |
+
if content is not None:
|
| 111 |
+
payload["content_role"] = getattr(content, "role", None)
|
| 112 |
+
parts_info = []
|
| 113 |
+
for part in getattr(content, "parts", None) or []:
|
| 114 |
+
fr = getattr(part, "function_response", None)
|
| 115 |
+
info: dict[str, Any] = {
|
| 116 |
+
"has_text": bool(getattr(part, "text", None)),
|
| 117 |
+
"has_function_call": bool(getattr(part, "function_call", None)),
|
| 118 |
+
"has_function_response": bool(fr),
|
| 119 |
+
}
|
| 120 |
+
if fr is not None:
|
| 121 |
+
info["fr_name"] = getattr(fr, "name", None)
|
| 122 |
+
resp = getattr(fr, "response", None)
|
| 123 |
+
info["fr_response_type"] = type(resp).__name__
|
| 124 |
+
info["fr_response_repr"] = repr(resp)[:1500]
|
| 125 |
+
if isinstance(resp, dict):
|
| 126 |
+
info["fr_response_keys"] = list(resp.keys())
|
| 127 |
+
parts_info.append(info)
|
| 128 |
+
payload["parts"] = parts_info
|
| 129 |
+
log_path = Path(__file__).resolve().parents[1] / "logs" / "adk-events.log"
|
| 130 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
with log_path.open("a", encoding="utf-8") as fh:
|
| 132 |
+
fh.write(json.dumps(payload, default=str) + "\n")
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
try:
|
| 135 |
+
from pathlib import Path
|
| 136 |
+
log_path = Path(__file__).resolve().parents[1] / "logs" / "adk-events.log"
|
| 137 |
+
with log_path.open("a", encoding="utf-8") as fh:
|
| 138 |
+
fh.write(f"DUMP-ERR: {exc}\n")
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
|
| 142 |
+
@staticmethod
|
| 143 |
+
def _extract_tool_responses(event: Any) -> list[dict[str, Any]]:
|
| 144 |
+
"""Pull MCP tool responses out of an ADK event so we can render their UI payloads."""
|
| 145 |
+
content = getattr(event, "content", None)
|
| 146 |
+
if content is None:
|
| 147 |
+
return []
|
| 148 |
+
parts = getattr(content, "parts", None) or []
|
| 149 |
+
|
| 150 |
+
responses: list[dict[str, Any]] = []
|
| 151 |
+
for part in parts:
|
| 152 |
+
function_response = getattr(part, "function_response", None)
|
| 153 |
+
if function_response is None:
|
| 154 |
+
continue
|
| 155 |
+
name = getattr(function_response, "name", None) or ""
|
| 156 |
+
response = getattr(function_response, "response", None)
|
| 157 |
+
payload = ADKRuntime._coerce_tool_payload(response)
|
| 158 |
+
if payload is None:
|
| 159 |
+
continue
|
| 160 |
+
responses.append({"name": name, "response": payload})
|
| 161 |
+
|
| 162 |
+
# ADK sometimes surfaces tool results via convenience attributes on the
|
| 163 |
+
# event itself (e.g. event.actions.state_delta or event.tool_responses).
|
| 164 |
+
# Dig those up too so we don't miss the UI payload.
|
| 165 |
+
for attr in ("tool_responses", "function_responses"):
|
| 166 |
+
extra = getattr(event, attr, None)
|
| 167 |
+
if not extra:
|
| 168 |
+
continue
|
| 169 |
+
if isinstance(extra, list):
|
| 170 |
+
for item in extra:
|
| 171 |
+
name = getattr(item, "name", None) or ""
|
| 172 |
+
response = getattr(item, "response", None)
|
| 173 |
+
payload = ADKRuntime._coerce_tool_payload(response)
|
| 174 |
+
if payload is not None:
|
| 175 |
+
responses.append({"name": name, "response": payload})
|
| 176 |
+
|
| 177 |
+
return responses
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
def _coerce_tool_payload(value: Any) -> dict[str, Any] | None:
|
| 181 |
+
"""Unwrap a tool response into a plain dict.
|
| 182 |
+
|
| 183 |
+
ADK + MCP delivers tool results as one of several shapes:
|
| 184 |
+
1. ``{"result": <plain dict>}`` — direct ADK function tool
|
| 185 |
+
2. ``{"result": CallToolResult(...)}``— MCP toolset wraps the response
|
| 186 |
+
into an ``mcp.types.CallToolResult`` whose ``content`` is a list of
|
| 187 |
+
``TextContent`` items carrying JSON-encoded text.
|
| 188 |
+
3. ``CallToolResult(...)`` — same as above, unwrapped.
|
| 189 |
+
4. plain dict already.
|
| 190 |
+
"""
|
| 191 |
+
import json
|
| 192 |
+
|
| 193 |
+
# Step 1: pull off the optional "result" wrapper.
|
| 194 |
+
if isinstance(value, dict) and "result" in value and len(value) == 1:
|
| 195 |
+
value = value["result"]
|
| 196 |
+
|
| 197 |
+
# Step 2: plain dict — done.
|
| 198 |
+
if isinstance(value, dict):
|
| 199 |
+
return value
|
| 200 |
+
|
| 201 |
+
# Step 3: MCP CallToolResult / TextContent. Detect by duck-typing the
|
| 202 |
+
# ``content`` attribute that holds a list of items with ``.text``.
|
| 203 |
+
content_items = getattr(value, "content", None)
|
| 204 |
+
if isinstance(content_items, list) and content_items:
|
| 205 |
+
for item in content_items:
|
| 206 |
+
text = getattr(item, "text", None)
|
| 207 |
+
if isinstance(text, str) and text.strip():
|
| 208 |
+
try:
|
| 209 |
+
parsed = json.loads(text)
|
| 210 |
+
if isinstance(parsed, dict):
|
| 211 |
+
return parsed
|
| 212 |
+
except json.JSONDecodeError:
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
# Step 4: protobuf Struct fallback.
|
| 216 |
+
to_dict = getattr(value, "to_dict", None)
|
| 217 |
+
if callable(to_dict):
|
| 218 |
+
try:
|
| 219 |
+
converted = to_dict()
|
| 220 |
+
if isinstance(converted, dict):
|
| 221 |
+
return converted.get("result", converted)
|
| 222 |
+
except Exception:
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
return None
|
backend/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core backend package for config, models, and shared utilities."""
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from pydantic import Field
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
ROOT_DIR = Path(__file__).resolve().parents[2]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
app_name: str = Field(default="Recruitment Agent API", validation_alias="APP_NAME")
|
| 14 |
+
environment: str = Field(default="development", validation_alias="APP_ENV")
|
| 15 |
+
host: str = Field(default="0.0.0.0", validation_alias="APP_HOST")
|
| 16 |
+
port: int = Field(default=7860, validation_alias="APP_PORT")
|
| 17 |
+
cors_origins: str = Field(default="*", validation_alias="CORS_ORIGINS")
|
| 18 |
+
|
| 19 |
+
gemini_api_key: str = Field(default="", validation_alias="GEMINI_API_KEY")
|
| 20 |
+
gemini_model: str = Field(default="gemini-2.5-flash", validation_alias="GEMINI_MODEL")
|
| 21 |
+
embedding_model: str = Field(
|
| 22 |
+
default="models/text-embedding-004",
|
| 23 |
+
validation_alias="GEMINI_EMBEDDING_MODEL",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
qdrant_url: str | None = Field(default=None, validation_alias="QDRANT_URL")
|
| 27 |
+
qdrant_api_key: str | None = Field(default=None, validation_alias="QDRANT_API_KEY")
|
| 28 |
+
qdrant_collection: str = Field(
|
| 29 |
+
default="recruitment_candidates",
|
| 30 |
+
validation_alias="QDRANT_COLLECTION",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Generic S3-compatible storage. Works with Supabase Storage, Backblaze B2,
|
| 34 |
+
# AWS S3, MinIO, etc. — just point S3_ENDPOINT at the provider's URL.
|
| 35 |
+
s3_endpoint: str | None = Field(default=None, validation_alias="S3_ENDPOINT")
|
| 36 |
+
s3_access_key_id: str | None = Field(default=None, validation_alias="S3_ACCESS_KEY_ID")
|
| 37 |
+
s3_secret_access_key: str | None = Field(
|
| 38 |
+
default=None,
|
| 39 |
+
validation_alias="S3_SECRET_ACCESS_KEY",
|
| 40 |
+
)
|
| 41 |
+
s3_bucket: str | None = Field(default=None, validation_alias="S3_BUCKET")
|
| 42 |
+
s3_region: str = Field(default="us-east-1", validation_alias="S3_REGION")
|
| 43 |
+
s3_public_base_url: str | None = Field(
|
| 44 |
+
default=None,
|
| 45 |
+
validation_alias="S3_PUBLIC_BASE_URL",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
sqlite_db_path: str = Field(
|
| 49 |
+
default=str(ROOT_DIR / "backend" / "data" / "candidates.db"),
|
| 50 |
+
validation_alias="SQLITE_DB_PATH",
|
| 51 |
+
)
|
| 52 |
+
uploads_dir: str = Field(
|
| 53 |
+
default=str(ROOT_DIR / "backend" / "data" / "uploads"),
|
| 54 |
+
validation_alias="UPLOADS_DIR",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
mcp_server_base_url: str = Field(
|
| 58 |
+
default="http://127.0.0.1:7860/mcp-server/sse",
|
| 59 |
+
validation_alias="MCP_SERVER_BASE_URL",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
chunk_size: int = Field(default=1100, validation_alias="CHUNK_SIZE")
|
| 63 |
+
chunk_overlap: int = Field(default=150, validation_alias="CHUNK_OVERLAP")
|
| 64 |
+
max_chunks_per_document: int = Field(default=50, validation_alias="MAX_CHUNKS_PER_DOC")
|
| 65 |
+
|
| 66 |
+
smtp_host: str | None = Field(default=None, validation_alias="SMTP_HOST")
|
| 67 |
+
smtp_port: int = Field(default=587, validation_alias="SMTP_PORT")
|
| 68 |
+
smtp_user: str | None = Field(default=None, validation_alias="SMTP_USER")
|
| 69 |
+
smtp_password: str | None = Field(default=None, validation_alias="SMTP_PASSWORD")
|
| 70 |
+
smtp_from: str | None = Field(default=None, validation_alias="SMTP_FROM")
|
| 71 |
+
smtp_use_tls: bool = Field(default=True, validation_alias="SMTP_USE_TLS")
|
| 72 |
+
calendly_event_url: str | None = Field(default=None, validation_alias="CALENDLY_EVENT_URL")
|
| 73 |
+
|
| 74 |
+
model_config = SettingsConfigDict(
|
| 75 |
+
env_file=(str(ROOT_DIR / ".env"), str(ROOT_DIR.parent / ".env")),
|
| 76 |
+
env_file_encoding="utf-8",
|
| 77 |
+
extra="ignore",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def sqlite_url(self) -> str:
|
| 82 |
+
return f"sqlite:///{Path(self.sqlite_db_path).resolve()}"
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def cors_origin_list(self) -> list[str]:
|
| 86 |
+
if self.cors_origins.strip() == "*":
|
| 87 |
+
return ["*"]
|
| 88 |
+
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def has_qdrant_config(self) -> bool:
|
| 92 |
+
return bool(self.qdrant_url and self.qdrant_api_key)
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def has_s3_config(self) -> bool:
|
| 96 |
+
return bool(
|
| 97 |
+
self.s3_endpoint
|
| 98 |
+
and self.s3_access_key_id
|
| 99 |
+
and self.s3_secret_access_key
|
| 100 |
+
and self.s3_bucket
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def has_smtp_config(self) -> bool:
|
| 105 |
+
return bool(self.smtp_host and self.smtp_from)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@lru_cache(maxsize=1)
|
| 109 |
+
def get_settings() -> Settings:
|
| 110 |
+
return Settings()
|
backend/core/db.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Generator
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import create_engine
|
| 7 |
+
from sqlalchemy.orm import Session, sessionmaker
|
| 8 |
+
|
| 9 |
+
from core.config import get_settings
|
| 10 |
+
from core.models import Base
|
| 11 |
+
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
|
| 14 |
+
sqlite_path = Path(settings.sqlite_db_path)
|
| 15 |
+
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
engine = create_engine(
|
| 18 |
+
settings.sqlite_url,
|
| 19 |
+
connect_args={"check_same_thread": False},
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def init_db() -> None:
|
| 26 |
+
Base.metadata.create_all(bind=engine)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_db() -> Generator[Session, None, None]:
|
| 30 |
+
db = SessionLocal()
|
| 31 |
+
try:
|
| 32 |
+
yield db
|
| 33 |
+
finally:
|
| 34 |
+
db.close()
|
backend/core/models.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import DateTime, Float, Integer, String, Text
|
| 6 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Base(DeclarativeBase):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Candidate(Base):
|
| 14 |
+
__tablename__ = "candidates"
|
| 15 |
+
|
| 16 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 17 |
+
external_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
| 18 |
+
|
| 19 |
+
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 20 |
+
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 21 |
+
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 22 |
+
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 23 |
+
|
| 24 |
+
years_experience: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 25 |
+
skills_csv: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 26 |
+
|
| 27 |
+
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 28 |
+
source_resume_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
| 29 |
+
source_resume_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
| 30 |
+
|
| 31 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 32 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 33 |
+
DateTime,
|
| 34 |
+
default=datetime.utcnow,
|
| 35 |
+
onupdate=datetime.utcnow,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class QueryAudit(Base):
|
| 40 |
+
__tablename__ = "query_audit"
|
| 41 |
+
|
| 42 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 43 |
+
session_id: Mapped[str] = mapped_column(String(128), index=True)
|
| 44 |
+
query: Mapped[str] = mapped_column(Text)
|
| 45 |
+
top_k: Mapped[int] = mapped_column(Integer, default=5)
|
| 46 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ApplicationStatus(Base):
|
| 50 |
+
__tablename__ = "application_status"
|
| 51 |
+
|
| 52 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 53 |
+
candidate_id: Mapped[str] = mapped_column(String(64), index=True)
|
| 54 |
+
candidate_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 55 |
+
role_applied: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 56 |
+
stage: Mapped[str] = mapped_column(String(128), default="Applied")
|
| 57 |
+
status: Mapped[str] = mapped_column(String(128), default="Open")
|
| 58 |
+
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 59 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 60 |
+
DateTime,
|
| 61 |
+
default=datetime.utcnow,
|
| 62 |
+
onupdate=datetime.utcnow,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class InterviewRecord(Base):
|
| 67 |
+
__tablename__ = "interview_records"
|
| 68 |
+
|
| 69 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 70 |
+
candidate_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
| 71 |
+
candidate_name: Mapped[str] = mapped_column(String(255))
|
| 72 |
+
candidate_email: Mapped[str] = mapped_column(String(255), index=True)
|
| 73 |
+
|
| 74 |
+
interviewer_name: Mapped[str] = mapped_column(String(255))
|
| 75 |
+
interviewer_email: Mapped[str] = mapped_column(String(255), index=True)
|
| 76 |
+
interview_round: Mapped[str] = mapped_column(String(128), default="Screening")
|
| 77 |
+
|
| 78 |
+
scheduled_on: Mapped[datetime] = mapped_column(DateTime)
|
| 79 |
+
end_time: Mapped[datetime] = mapped_column(DateTime)
|
| 80 |
+
duration_minutes: Mapped[int] = mapped_column(Integer, default=60)
|
| 81 |
+
|
| 82 |
+
meeting_link: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
| 83 |
+
status: Mapped[str] = mapped_column(String(64), default="Scheduled")
|
| 84 |
+
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 85 |
+
|
| 86 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 87 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 88 |
+
DateTime,
|
| 89 |
+
default=datetime.utcnow,
|
| 90 |
+
onupdate=datetime.utcnow,
|
| 91 |
+
)
|
backend/core/schemas.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ChatRequest(BaseModel):
|
| 9 |
+
message: str = Field(min_length=1)
|
| 10 |
+
session_id: str = Field(default="web-session")
|
| 11 |
+
user_id: str = Field(default="web-user")
|
| 12 |
+
top_k: int = Field(default=5, ge=1, le=25)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CandidateCard(BaseModel):
|
| 16 |
+
title: str
|
| 17 |
+
subtitle: str | None = None
|
| 18 |
+
tags: list[str] = Field(default_factory=list)
|
| 19 |
+
meta: dict[str, Any] = Field(default_factory=dict)
|
| 20 |
+
actions: list[dict[str, str]] = Field(default_factory=list)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GenUiPayload(BaseModel):
|
| 24 |
+
summary: str | None = None
|
| 25 |
+
cards: list[CandidateCard] = Field(default_factory=list)
|
| 26 |
+
chart: dict[str, Any] | None = None
|
| 27 |
+
table: dict[str, Any] | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class UploadResponse(BaseModel):
|
| 31 |
+
candidate_id: str
|
| 32 |
+
file_name: str
|
| 33 |
+
message: str
|
| 34 |
+
chunks_indexed: int
|
| 35 |
+
r2_url: str | None = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ToolStatusResponse(BaseModel):
|
| 39 |
+
gemini_configured: bool
|
| 40 |
+
qdrant_configured: bool
|
| 41 |
+
r2_configured: bool
|
| 42 |
+
mcp_url: str
|
backend/core/utils.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def chunk_text_for_stream(text: str, chunk_size: int = 36) -> list[str]:
|
| 9 |
+
if not text:
|
| 10 |
+
return []
|
| 11 |
+
chunks: list[str] = []
|
| 12 |
+
for idx in range(0, len(text), chunk_size):
|
| 13 |
+
chunks.append(text[idx : idx + chunk_size])
|
| 14 |
+
return chunks
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def format_sse(event: str, data: dict[str, Any]) -> str:
|
| 18 |
+
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def extract_json_object(raw_text: str) -> dict[str, Any] | None:
|
| 22 |
+
stripped = raw_text.strip()
|
| 23 |
+
if not stripped:
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
loaded = json.loads(stripped)
|
| 28 |
+
if isinstance(loaded, dict):
|
| 29 |
+
return loaded
|
| 30 |
+
except json.JSONDecodeError:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
fenced = re.findall(r"```json\s*(\{.*?\})\s*```", stripped, flags=re.DOTALL)
|
| 34 |
+
for block in fenced:
|
| 35 |
+
try:
|
| 36 |
+
loaded = json.loads(block)
|
| 37 |
+
if isinstance(loaded, dict):
|
| 38 |
+
return loaded
|
| 39 |
+
except json.JSONDecodeError:
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
brace_match = re.search(r"(\{.*\})", stripped, flags=re.DOTALL)
|
| 43 |
+
if brace_match:
|
| 44 |
+
candidate = brace_match.group(1)
|
| 45 |
+
try:
|
| 46 |
+
loaded = json.loads(candidate)
|
| 47 |
+
if isinstance(loaded, dict):
|
| 48 |
+
return loaded
|
| 49 |
+
except json.JSONDecodeError:
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Intent phrases — any one of these anywhere in the message indicates the user wants
|
| 56 |
+
# the system to surface people. Designed to catch natural phrasing.
|
| 57 |
+
_CANDIDATE_SEARCH_INTENTS = (
|
| 58 |
+
"give me",
|
| 59 |
+
"show me",
|
| 60 |
+
"find me",
|
| 61 |
+
"fetch me",
|
| 62 |
+
"get me",
|
| 63 |
+
"send me",
|
| 64 |
+
"bring me",
|
| 65 |
+
"i need",
|
| 66 |
+
"i want",
|
| 67 |
+
"i'm looking",
|
| 68 |
+
"im looking",
|
| 69 |
+
"looking for",
|
| 70 |
+
"looking to hire",
|
| 71 |
+
"want to hire",
|
| 72 |
+
"need to hire",
|
| 73 |
+
"who has",
|
| 74 |
+
"who knows",
|
| 75 |
+
"who can",
|
| 76 |
+
"anyone with",
|
| 77 |
+
"any candidate",
|
| 78 |
+
"any developer",
|
| 79 |
+
"any engineer",
|
| 80 |
+
"any profile",
|
| 81 |
+
"search ",
|
| 82 |
+
"find ",
|
| 83 |
+
"fetch ",
|
| 84 |
+
"rank ",
|
| 85 |
+
"shortlist",
|
| 86 |
+
"list ",
|
| 87 |
+
"show ",
|
| 88 |
+
"match ",
|
| 89 |
+
"filter ",
|
| 90 |
+
"top ",
|
| 91 |
+
"best ",
|
| 92 |
+
"candidate for",
|
| 93 |
+
"candidates for",
|
| 94 |
+
"candidate with",
|
| 95 |
+
"candidates with",
|
| 96 |
+
"candidate having",
|
| 97 |
+
"candidates having",
|
| 98 |
+
"candidate who",
|
| 99 |
+
"candidates who",
|
| 100 |
+
"engineer for",
|
| 101 |
+
"engineers for",
|
| 102 |
+
"developer for",
|
| 103 |
+
"developers for",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
_CANDIDATE_ROLE_WORDS = (
|
| 107 |
+
"engineer",
|
| 108 |
+
"engineers",
|
| 109 |
+
"developer",
|
| 110 |
+
"developers",
|
| 111 |
+
"scientist",
|
| 112 |
+
"scientists",
|
| 113 |
+
"analyst",
|
| 114 |
+
"analysts",
|
| 115 |
+
"designer",
|
| 116 |
+
"designers",
|
| 117 |
+
"manager",
|
| 118 |
+
"managers",
|
| 119 |
+
"architect",
|
| 120 |
+
"architects",
|
| 121 |
+
"intern",
|
| 122 |
+
"interns",
|
| 123 |
+
"lead",
|
| 124 |
+
"leads",
|
| 125 |
+
"consultant",
|
| 126 |
+
"consultants",
|
| 127 |
+
"specialist",
|
| 128 |
+
"specialists",
|
| 129 |
+
"candidate",
|
| 130 |
+
"candidates",
|
| 131 |
+
"resume",
|
| 132 |
+
"resumes",
|
| 133 |
+
"profile",
|
| 134 |
+
"profiles",
|
| 135 |
+
"people",
|
| 136 |
+
"talent",
|
| 137 |
+
"experience", # "candidate for AI experience"
|
| 138 |
+
"expertise",
|
| 139 |
+
"skill",
|
| 140 |
+
"skills",
|
| 141 |
+
"background",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
_CANDIDATE_SEARCH_NEGATIVE_PHRASES = (
|
| 145 |
+
"this pdf",
|
| 146 |
+
"the pdf",
|
| 147 |
+
"this resume",
|
| 148 |
+
"the resume",
|
| 149 |
+
"this file",
|
| 150 |
+
"the file",
|
| 151 |
+
"uploaded pdf",
|
| 152 |
+
"uploaded resume",
|
| 153 |
+
"uploaded file",
|
| 154 |
+
"this candidate",
|
| 155 |
+
"the candidate",
|
| 156 |
+
"this person",
|
| 157 |
+
"the person",
|
| 158 |
+
"this applicant",
|
| 159 |
+
"the applicant",
|
| 160 |
+
"this profile",
|
| 161 |
+
"the profile",
|
| 162 |
+
"their resume",
|
| 163 |
+
"their profile",
|
| 164 |
+
"their experience",
|
| 165 |
+
"his resume",
|
| 166 |
+
"her resume",
|
| 167 |
+
"his profile",
|
| 168 |
+
"her profile",
|
| 169 |
+
"about him",
|
| 170 |
+
"about her",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def looks_like_candidate_search(query: str) -> bool:
|
| 175 |
+
"""Heuristic: does the user's message ask the system to find people?
|
| 176 |
+
|
| 177 |
+
Accepts natural phrasing — "give me candidate for AI experience", "I need a
|
| 178 |
+
frontend developer", "anyone with NLP background", "looking for top python
|
| 179 |
+
engineers in Bangalore", etc. Requires both an *intent phrase* (give me / show
|
| 180 |
+
me / find / I need / looking for / etc.) and a *role/skill noun* (engineer /
|
| 181 |
+
developer / candidate / experience / skill / etc.).
|
| 182 |
+
|
| 183 |
+
Filters out chat actions and messages about a specific uploaded resume so we
|
| 184 |
+
don't hijack other flows.
|
| 185 |
+
"""
|
| 186 |
+
lowered = (query or "").strip().lower()
|
| 187 |
+
if not lowered:
|
| 188 |
+
return False
|
| 189 |
+
|
| 190 |
+
if lowered.startswith(("open_profile", "shortlist:", "details:", "publish_job_posting", "edit_job_posting")):
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
if any(neg in lowered for neg in _CANDIDATE_SEARCH_NEGATIVE_PHRASES):
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
has_intent = any(intent in lowered for intent in _CANDIDATE_SEARCH_INTENTS)
|
| 197 |
+
has_role = any(role in lowered for role in _CANDIDATE_ROLE_WORDS)
|
| 198 |
+
return has_intent and has_role
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def parse_action_command(query: str) -> tuple[str, str] | None:
|
| 202 |
+
"""Detect chat messages that look like card action commands such as ``open_profile:<id>``."""
|
| 203 |
+
if not query:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
cleaned = query.strip()
|
| 207 |
+
if ":" not in cleaned:
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
head, _, tail = cleaned.partition(":")
|
| 211 |
+
head = head.strip().lower()
|
| 212 |
+
tail = tail.strip()
|
| 213 |
+
if not tail or " " in head:
|
| 214 |
+
return None
|
| 215 |
+
|
| 216 |
+
if head in {"open_profile", "details", "shortlist", "publish_job_posting", "edit_job_posting"}:
|
| 217 |
+
return head, tail
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def looks_like_c1_response(text: str) -> bool:
|
| 222 |
+
lowered = text.lower()
|
| 223 |
+
has_content_root = "<content" in lowered and "</content>" in lowered
|
| 224 |
+
if not has_content_root:
|
| 225 |
+
return False
|
| 226 |
+
return (
|
| 227 |
+
"<custom_markdown" in lowered
|
| 228 |
+
or "<custommarkdown" in lowered
|
| 229 |
+
or "<artifact" in lowered
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def wrap_text_as_c1(text: str) -> str:
|
| 234 |
+
"""Wrap plain markdown in the schema-stable C1 envelope.
|
| 235 |
+
|
| 236 |
+
Thesys's <C1Component> renders <custom_markdown> reliably without their
|
| 237 |
+
fine-tuned model — anything richer requires the API. Wrapping any agent
|
| 238 |
+
text in this envelope means every response renders through the SDK,
|
| 239 |
+
giving uniform typography/styling.
|
| 240 |
+
"""
|
| 241 |
+
text = (text or "").strip()
|
| 242 |
+
if not text:
|
| 243 |
+
return ""
|
| 244 |
+
if looks_like_c1_response(text):
|
| 245 |
+
return text
|
| 246 |
+
safe = (
|
| 247 |
+
text.replace("&", "&")
|
| 248 |
+
.replace("<", "<")
|
| 249 |
+
.replace(">", ">")
|
| 250 |
+
)
|
| 251 |
+
return f"<content><custom_markdown>{safe}</custom_markdown></content>"
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def build_c1_response(text: str, payload: dict[str, Any] | None = None) -> str:
|
| 255 |
+
clean_text = (text or "").strip()
|
| 256 |
+
if clean_text and looks_like_c1_response(clean_text):
|
| 257 |
+
return clean_text
|
| 258 |
+
|
| 259 |
+
if payload:
|
| 260 |
+
payload_c1 = payload.get("c1_response") or payload.get("c1Response")
|
| 261 |
+
if isinstance(payload_c1, str):
|
| 262 |
+
payload_c1 = payload_c1.strip()
|
| 263 |
+
if payload_c1 and looks_like_c1_response(payload_c1):
|
| 264 |
+
return payload_c1
|
| 265 |
+
|
| 266 |
+
return ""
|
backend/data/policies.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"leave policy": "Employees are eligible for 18 paid leave days per year, plus national holidays. Casual leave requests should be submitted at least 48 hours in advance whenever possible.",
|
| 3 |
+
"work from home policy": "Hybrid work is allowed for eligible roles up to 3 days per week with manager approval. Team-level coverage and client commitments take priority.",
|
| 4 |
+
"referral policy": "Employees can refer candidates through the recruitment portal. Referral bonuses are paid after the referred candidate completes the probation period.",
|
| 5 |
+
"interview policy": "Interview feedback must be submitted within 24 hours. Each candidate should be evaluated on role-specific rubrics and structured competency notes.",
|
| 6 |
+
"code of conduct": "All employees must maintain professional behavior, protect confidential data, and follow anti-harassment and equal opportunity policies."
|
| 7 |
+
}
|
backend/data/seed_candidates.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import random
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from faker import Faker
|
| 9 |
+
|
| 10 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
if str(BACKEND_ROOT) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 13 |
+
|
| 14 |
+
from core.db import SessionLocal, init_db
|
| 15 |
+
from services.ingestion_service import IngestionService
|
| 16 |
+
|
| 17 |
+
faker = Faker()
|
| 18 |
+
|
| 19 |
+
SKILL_POOLS = [
|
| 20 |
+
["python", "fastapi", "sql", "docker"],
|
| 21 |
+
["java", "spring", "kubernetes", "aws"],
|
| 22 |
+
["react", "typescript", "next.js", "node"],
|
| 23 |
+
["ml", "nlp", "llm", "python"],
|
| 24 |
+
["data engineering", "airflow", "spark", "sql"],
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_synthetic_resume(name: str, years: int, skills: list[str], location: str) -> str:
|
| 29 |
+
return (
|
| 30 |
+
f"{name}\n"
|
| 31 |
+
f"Location: {location}\n"
|
| 32 |
+
f"Experience: {years} years\n"
|
| 33 |
+
f"Skills: {', '.join(skills)}\n"
|
| 34 |
+
f"Summary: {faker.paragraph(nb_sentences=6)}\n"
|
| 35 |
+
f"Projects: {faker.paragraph(nb_sentences=4)}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def seed(count: int) -> None:
|
| 40 |
+
init_db()
|
| 41 |
+
ingestion = IngestionService()
|
| 42 |
+
|
| 43 |
+
inserted = 0
|
| 44 |
+
with SessionLocal() as db:
|
| 45 |
+
for _ in range(count):
|
| 46 |
+
name = faker.name()
|
| 47 |
+
years = random.randint(1, 14)
|
| 48 |
+
location = faker.city()
|
| 49 |
+
skills = random.choice(SKILL_POOLS)
|
| 50 |
+
resume_text = build_synthetic_resume(name=name, years=years, skills=skills, location=location)
|
| 51 |
+
|
| 52 |
+
ingestion.ingest_text_profile(
|
| 53 |
+
db,
|
| 54 |
+
candidate_name=name,
|
| 55 |
+
resume_text=resume_text,
|
| 56 |
+
location=location,
|
| 57 |
+
skills=skills,
|
| 58 |
+
years_experience=float(years),
|
| 59 |
+
)
|
| 60 |
+
inserted += 1
|
| 61 |
+
|
| 62 |
+
print(f"Seed complete. Inserted {inserted} synthetic candidates.")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
parser = argparse.ArgumentParser(description="Seed synthetic candidate profiles.")
|
| 67 |
+
parser.add_argument("--count", type=int, default=200, help="Number of synthetic candidates")
|
| 68 |
+
args = parser.parse_args()
|
| 69 |
+
seed(args.count)
|
backend/main.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
+
|
| 12 |
+
from agent.runtime import ADKRuntime
|
| 13 |
+
from core.config import get_settings
|
| 14 |
+
from core.db import SessionLocal, init_db
|
| 15 |
+
from core.schemas import ChatRequest, ToolStatusResponse, UploadResponse
|
| 16 |
+
from core.utils import (
|
| 17 |
+
build_c1_response,
|
| 18 |
+
chunk_text_for_stream,
|
| 19 |
+
extract_json_object,
|
| 20 |
+
format_sse,
|
| 21 |
+
looks_like_candidate_search,
|
| 22 |
+
parse_action_command,
|
| 23 |
+
wrap_text_as_c1,
|
| 24 |
+
)
|
| 25 |
+
from mcp_server.server import mcp_app
|
| 26 |
+
from services.candidate_service import CandidateService
|
| 27 |
+
from services.ingestion_service import IngestionService
|
| 28 |
+
from services.reference_data_service import ingest_reference_resumes, sync_reference_resumes_to_local
|
| 29 |
+
from services.search_service import QdrantSearchService
|
| 30 |
+
|
| 31 |
+
load_dotenv()
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
settings = get_settings()
|
| 35 |
+
|
| 36 |
+
app = FastAPI(title=settings.app_name)
|
| 37 |
+
app.mount("/mcp-server", mcp_app)
|
| 38 |
+
|
| 39 |
+
app.add_middleware(
|
| 40 |
+
CORSMiddleware,
|
| 41 |
+
allow_origins=settings.cors_origin_list,
|
| 42 |
+
allow_credentials=True,
|
| 43 |
+
allow_methods=["*"],
|
| 44 |
+
allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
runtime = ADKRuntime()
|
| 48 |
+
ingestion_service = IngestionService()
|
| 49 |
+
search_service = QdrantSearchService()
|
| 50 |
+
candidate_service = CandidateService()
|
| 51 |
+
|
| 52 |
+
# Tracks the most recent resume uploads per session so the agent can answer
|
| 53 |
+
# "tell me about the PDF" without the user pasting the candidate id back in.
|
| 54 |
+
_session_recent_uploads: dict[str, dict[str, str]] = {}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _remember_upload(session_id: str | None, candidate_id: str, file_name: str) -> None:
|
| 58 |
+
if not session_id:
|
| 59 |
+
return
|
| 60 |
+
_session_recent_uploads[session_id] = {
|
| 61 |
+
"candidate_id": candidate_id,
|
| 62 |
+
"file_name": file_name,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _build_session_upload_hint(session_id: str) -> str:
|
| 67 |
+
info = _session_recent_uploads.get(session_id)
|
| 68 |
+
if not info:
|
| 69 |
+
return ""
|
| 70 |
+
return (
|
| 71 |
+
"\n\n[Recent upload context: the user just uploaded the resume "
|
| 72 |
+
f"`{info['file_name']}` and it has been ingested as candidate id "
|
| 73 |
+
f"`{info['candidate_id']}`. If their question refers to 'this pdf', "
|
| 74 |
+
"'the resume', 'the file', or similar, it means this candidate. "
|
| 75 |
+
"Call candidate_metadata_query with that candidate_id to fetch the "
|
| 76 |
+
"stored summary, or use open_profile via the standard action.]"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.on_event("startup")
|
| 81 |
+
def startup_event() -> None:
|
| 82 |
+
init_db()
|
| 83 |
+
Path(settings.uploads_dir).mkdir(parents=True, exist_ok=True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.get("/health")
|
| 87 |
+
def health() -> dict:
|
| 88 |
+
return {"ok": True, "service": settings.app_name}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.get("/mcp-health")
|
| 92 |
+
def mcp_health() -> dict:
|
| 93 |
+
return {
|
| 94 |
+
"ok": True,
|
| 95 |
+
"mcp_endpoint": settings.mcp_server_base_url,
|
| 96 |
+
"tools": [
|
| 97 |
+
"ingest_resume_pdf",
|
| 98 |
+
"semantic_candidate_search",
|
| 99 |
+
"candidate_metadata_query",
|
| 100 |
+
"compute_job_match_score",
|
| 101 |
+
"get_policy_info",
|
| 102 |
+
"manage_application_status",
|
| 103 |
+
"manage_interview_records",
|
| 104 |
+
"generate_job_posting",
|
| 105 |
+
"bulk_ingest_reference_resumes",
|
| 106 |
+
"email_compose",
|
| 107 |
+
],
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.get("/api/tooling/status", response_model=ToolStatusResponse)
|
| 112 |
+
def tooling_status() -> ToolStatusResponse:
|
| 113 |
+
return ToolStatusResponse(
|
| 114 |
+
gemini_configured=bool(settings.gemini_api_key),
|
| 115 |
+
qdrant_configured=settings.has_qdrant_config,
|
| 116 |
+
r2_configured=settings.has_s3_config,
|
| 117 |
+
mcp_url=settings.mcp_server_base_url,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@app.post("/api/upload", response_model=UploadResponse)
|
| 122 |
+
async def upload_resume(
|
| 123 |
+
file: UploadFile = File(...),
|
| 124 |
+
session_id: str | None = Query(default=None),
|
| 125 |
+
) -> UploadResponse:
|
| 126 |
+
file_name = file.filename or "resume.pdf"
|
| 127 |
+
if not file_name.lower().endswith(".pdf"):
|
| 128 |
+
raise HTTPException(status_code=400, detail="Only PDF files are supported in this endpoint.")
|
| 129 |
+
|
| 130 |
+
file_bytes = await file.read()
|
| 131 |
+
if not file_bytes:
|
| 132 |
+
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 133 |
+
|
| 134 |
+
save_path = Path(settings.uploads_dir) / file_name
|
| 135 |
+
save_path.write_bytes(file_bytes)
|
| 136 |
+
|
| 137 |
+
with SessionLocal() as db:
|
| 138 |
+
result = ingestion_service.ingest_pdf(
|
| 139 |
+
db,
|
| 140 |
+
file_name=file_name,
|
| 141 |
+
file_bytes=file_bytes,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
_remember_upload(session_id, result.candidate_id, result.file_name)
|
| 145 |
+
|
| 146 |
+
return UploadResponse(
|
| 147 |
+
candidate_id=result.candidate_id,
|
| 148 |
+
file_name=result.file_name,
|
| 149 |
+
chunks_indexed=result.chunks_indexed,
|
| 150 |
+
r2_url=result.r2_url,
|
| 151 |
+
message="Resume ingested successfully.",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@app.post("/api/bootstrap/reference-resumes")
|
| 156 |
+
def bootstrap_reference_resumes(
|
| 157 |
+
limit: int = Query(default=20, ge=1, le=200),
|
| 158 |
+
source_dir: str | None = Query(default=None),
|
| 159 |
+
force_reingest: bool = Query(default=False),
|
| 160 |
+
) -> dict:
|
| 161 |
+
summary = ingest_reference_resumes(
|
| 162 |
+
limit=limit,
|
| 163 |
+
source_dir=source_dir,
|
| 164 |
+
force_reingest=force_reingest,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"status": "success",
|
| 169 |
+
"source_dir": summary.source_dir,
|
| 170 |
+
"requested_limit": summary.requested_limit,
|
| 171 |
+
"files_seen": summary.files_seen,
|
| 172 |
+
"ingested": summary.ingested,
|
| 173 |
+
"skipped": summary.skipped,
|
| 174 |
+
"failed": summary.failed,
|
| 175 |
+
"failures": summary.failures,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@app.post("/api/bootstrap/sync-reference-resumes")
|
| 180 |
+
def sync_reference_resumes(
|
| 181 |
+
max_files: int = Query(default=120, ge=1, le=1000),
|
| 182 |
+
source_dir: str | None = Query(default=None),
|
| 183 |
+
) -> dict:
|
| 184 |
+
result = sync_reference_resumes_to_local(max_files=max_files, source_dir=source_dir)
|
| 185 |
+
return {
|
| 186 |
+
"status": "success",
|
| 187 |
+
**result,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _collect_tool_ui(tool_calls: list[dict]) -> dict:
|
| 192 |
+
"""Merge UI payloads from MCP tool responses (cards, summary, chart, markdown, tabs)."""
|
| 193 |
+
merged: dict = {}
|
| 194 |
+
cards: list = []
|
| 195 |
+
for call in tool_calls:
|
| 196 |
+
response = call.get("response") or {}
|
| 197 |
+
ui = response.get("ui") if isinstance(response, dict) else None
|
| 198 |
+
if not isinstance(ui, dict):
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
for key in ("summary", "c1_response", "chart", "table", "markdown", "tabs"):
|
| 202 |
+
value = ui.get(key)
|
| 203 |
+
if value and not merged.get(key):
|
| 204 |
+
merged[key] = value
|
| 205 |
+
|
| 206 |
+
ui_cards = ui.get("cards")
|
| 207 |
+
if isinstance(ui_cards, list):
|
| 208 |
+
cards.extend(ui_cards)
|
| 209 |
+
|
| 210 |
+
if cards:
|
| 211 |
+
merged["cards"] = cards
|
| 212 |
+
return merged
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _handle_action_command(command: str, target_id: str) -> tuple[str, dict | None]:
|
| 216 |
+
"""Resolve card actions like ``open_profile:<id>`` without invoking the LLM."""
|
| 217 |
+
if command == "open_profile" or command == "details":
|
| 218 |
+
with SessionLocal() as db:
|
| 219 |
+
candidate = candidate_service.get_candidate(db, target_id)
|
| 220 |
+
if candidate is None:
|
| 221 |
+
return (
|
| 222 |
+
f"I could not find a candidate with id `{target_id}`.",
|
| 223 |
+
None,
|
| 224 |
+
)
|
| 225 |
+
payload_json = candidate_service.build_profile_payload(candidate)
|
| 226 |
+
return payload_json["summary"], payload_json
|
| 227 |
+
|
| 228 |
+
if command == "shortlist":
|
| 229 |
+
with SessionLocal() as db:
|
| 230 |
+
candidate = candidate_service.get_candidate(db, target_id)
|
| 231 |
+
if candidate is None:
|
| 232 |
+
return f"Could not shortlist: candidate `{target_id}` not found.", None
|
| 233 |
+
payload_json = candidate_service.build_profile_payload(candidate)
|
| 234 |
+
message = (
|
| 235 |
+
f"Shortlisted **{candidate.full_name or candidate.external_id}** "
|
| 236 |
+
f"(id `{candidate.external_id}`). Track them in the application stages tool."
|
| 237 |
+
)
|
| 238 |
+
payload_json["summary"] = message
|
| 239 |
+
return message, payload_json
|
| 240 |
+
|
| 241 |
+
if command in {"publish_job_posting", "edit_job_posting"}:
|
| 242 |
+
verb = "publish" if command == "publish_job_posting" else "edit"
|
| 243 |
+
return (
|
| 244 |
+
f"Job posting `{target_id}` queued to {verb}. "
|
| 245 |
+
"Open the job postings file under backend/data to confirm.",
|
| 246 |
+
None,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return "", None
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@app.post("/api/chat")
|
| 253 |
+
async def chat_stream(payload: ChatRequest) -> StreamingResponse:
|
| 254 |
+
async def event_generator():
|
| 255 |
+
try:
|
| 256 |
+
with SessionLocal() as db:
|
| 257 |
+
candidate_service.audit_query(
|
| 258 |
+
db,
|
| 259 |
+
session_id=payload.session_id,
|
| 260 |
+
query=payload.message,
|
| 261 |
+
top_k=payload.top_k,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# If the user's message contains a demonstrative pointer to the most recent
|
| 265 |
+
# upload ("this pdf", "the candidate", "their resume", etc.) and we have a
|
| 266 |
+
# recorded upload for this session, short-circuit to the profile lookup so
|
| 267 |
+
# the agent never has to guess at the candidate id.
|
| 268 |
+
action = parse_action_command(payload.message)
|
| 269 |
+
if action is None:
|
| 270 |
+
lowered = payload.message.lower()
|
| 271 |
+
pdf_phrases = (
|
| 272 |
+
"this pdf",
|
| 273 |
+
"the pdf",
|
| 274 |
+
"this resume",
|
| 275 |
+
"the resume",
|
| 276 |
+
"this file",
|
| 277 |
+
"the file",
|
| 278 |
+
"uploaded pdf",
|
| 279 |
+
"uploaded resume",
|
| 280 |
+
"uploaded file",
|
| 281 |
+
"this candidate",
|
| 282 |
+
"the candidate",
|
| 283 |
+
"this person",
|
| 284 |
+
"the person",
|
| 285 |
+
"this applicant",
|
| 286 |
+
"the applicant",
|
| 287 |
+
"this profile",
|
| 288 |
+
"the profile",
|
| 289 |
+
"their resume",
|
| 290 |
+
"their profile",
|
| 291 |
+
"their experience",
|
| 292 |
+
"his resume",
|
| 293 |
+
"her resume",
|
| 294 |
+
"his profile",
|
| 295 |
+
"her profile",
|
| 296 |
+
"about him",
|
| 297 |
+
"about her",
|
| 298 |
+
"about them",
|
| 299 |
+
)
|
| 300 |
+
recent = _session_recent_uploads.get(payload.session_id) if payload.session_id else None
|
| 301 |
+
if recent and any(phrase in lowered for phrase in pdf_phrases):
|
| 302 |
+
action = ("open_profile", recent["candidate_id"])
|
| 303 |
+
|
| 304 |
+
if action is not None:
|
| 305 |
+
command, target_id = action
|
| 306 |
+
yield format_sse("status", {"message": f"Resolving {command} action"})
|
| 307 |
+
final_text, action_payload = _handle_action_command(command, target_id)
|
| 308 |
+
for token in chunk_text_for_stream(final_text):
|
| 309 |
+
yield format_sse("token", {"delta": token})
|
| 310 |
+
if action_payload is None:
|
| 311 |
+
action_payload = {}
|
| 312 |
+
# Drop any c1_response — the Thesys SDK rejects hand-rolled XML and
|
| 313 |
+
# shows a red 'Error while generating response' message. The card +
|
| 314 |
+
# markdown pipeline already renders the response correctly.
|
| 315 |
+
action_payload.pop("c1_response", None)
|
| 316 |
+
action_payload.pop("c1Response", None)
|
| 317 |
+
if not action_payload.get("summary"):
|
| 318 |
+
action_payload["summary"] = final_text
|
| 319 |
+
yield format_sse("genui", action_payload)
|
| 320 |
+
yield format_sse("done", {"message": final_text})
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
yield format_sse("status", {"message": "Running ADK recruitment agent"})
|
| 324 |
+
|
| 325 |
+
agent_message = payload.message
|
| 326 |
+
upload_hint = _build_session_upload_hint(payload.session_id)
|
| 327 |
+
if upload_hint:
|
| 328 |
+
agent_message = payload.message + upload_hint
|
| 329 |
+
|
| 330 |
+
turn = await runtime.run_turn_detailed(
|
| 331 |
+
message=agent_message,
|
| 332 |
+
user_id=payload.user_id,
|
| 333 |
+
session_id=payload.session_id,
|
| 334 |
+
)
|
| 335 |
+
final_text = turn.text or "I could not produce a response. Please retry with more detail."
|
| 336 |
+
|
| 337 |
+
for token in chunk_text_for_stream(final_text):
|
| 338 |
+
yield format_sse("token", {"delta": token})
|
| 339 |
+
|
| 340 |
+
payload_json = extract_json_object(final_text)
|
| 341 |
+
|
| 342 |
+
# Promote MCP tool UI payloads (e.g. job posting, interview invite) so the
|
| 343 |
+
# frontend can render rich Thesys C1 / cards even when the agent only emits
|
| 344 |
+
# plain markdown text.
|
| 345 |
+
tool_ui = _collect_tool_ui(turn.tool_calls)
|
| 346 |
+
if tool_ui:
|
| 347 |
+
if payload_json is None:
|
| 348 |
+
payload_json = {}
|
| 349 |
+
for key, value in tool_ui.items():
|
| 350 |
+
if value and not payload_json.get(key):
|
| 351 |
+
payload_json[key] = value
|
| 352 |
+
# Run the candidate-search fallback when the user's message clearly asks for
|
| 353 |
+
# a search AND we don't already have cards. The semantic search tool always
|
| 354 |
+
# returns a `ui` block (even with empty cards), so we can't just check
|
| 355 |
+
# `payload_json is None` — we have to look at whether actual cards were
|
| 356 |
+
# produced.
|
| 357 |
+
existing_cards = (payload_json or {}).get("cards") if isinstance(payload_json, dict) else None
|
| 358 |
+
cards_empty = not existing_cards
|
| 359 |
+
if cards_empty and looks_like_candidate_search(payload.message):
|
| 360 |
+
fallback_payload: dict | None = None
|
| 361 |
+
rows = search_service.semantic_search(query=payload.message, top_k=payload.top_k)
|
| 362 |
+
if rows:
|
| 363 |
+
fallback_payload = search_service.build_genui_payload(payload.message, rows)
|
| 364 |
+
else:
|
| 365 |
+
with SessionLocal() as db:
|
| 366 |
+
local_matches = candidate_service.search_candidates(
|
| 367 |
+
db,
|
| 368 |
+
query=payload.message,
|
| 369 |
+
limit=payload.top_k,
|
| 370 |
+
)
|
| 371 |
+
if local_matches:
|
| 372 |
+
fallback_payload = candidate_service.build_genui_payload(
|
| 373 |
+
payload.message,
|
| 374 |
+
local_matches,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
if fallback_payload:
|
| 378 |
+
if payload_json is None:
|
| 379 |
+
payload_json = fallback_payload
|
| 380 |
+
else:
|
| 381 |
+
for key, value in fallback_payload.items():
|
| 382 |
+
# Overwrite anything from the agent's empty tool response.
|
| 383 |
+
if value:
|
| 384 |
+
payload_json[key] = value
|
| 385 |
+
# Replace the agent's "no results" final text with our fallback summary
|
| 386 |
+
# so the bubble matches the cards we surface.
|
| 387 |
+
fb_summary = fallback_payload.get("summary")
|
| 388 |
+
if isinstance(fb_summary, str) and fb_summary.strip():
|
| 389 |
+
final_text = fb_summary
|
| 390 |
+
|
| 391 |
+
if payload_json is None:
|
| 392 |
+
payload_json = {}
|
| 393 |
+
|
| 394 |
+
# Strip any c1_response — Thesys C1Component shows 'Error while generating
|
| 395 |
+
# response' when handed XML it didn't generate itself. The markdown + cards
|
| 396 |
+
# + chart pipeline below already provides the interactive UI.
|
| 397 |
+
payload_json.pop("c1_response", None)
|
| 398 |
+
payload_json.pop("c1Response", None)
|
| 399 |
+
|
| 400 |
+
if payload_json and not payload_json.get("summary"):
|
| 401 |
+
payload_json["summary"] = final_text
|
| 402 |
+
|
| 403 |
+
yield format_sse("genui", payload_json)
|
| 404 |
+
|
| 405 |
+
done_message = final_text
|
| 406 |
+
summary = payload_json.get("summary") if isinstance(payload_json, dict) else None
|
| 407 |
+
if isinstance(summary, str) and summary.strip():
|
| 408 |
+
done_message = summary.strip()
|
| 409 |
+
|
| 410 |
+
yield format_sse("done", {"message": done_message})
|
| 411 |
+
except asyncio.CancelledError as exc: # pragma: no cover
|
| 412 |
+
logger.exception("Chat stream cancelled")
|
| 413 |
+
yield format_sse("error", {"message": f"Agent run cancelled: {exc}"})
|
| 414 |
+
except Exception as exc: # pragma: no cover
|
| 415 |
+
logger.exception("Chat stream failed")
|
| 416 |
+
yield format_sse("error", {"message": str(exc)})
|
| 417 |
+
|
| 418 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
@app.get("/api/candidates/{candidate_id}")
|
| 422 |
+
def get_candidate_detail(candidate_id: str) -> dict:
|
| 423 |
+
with SessionLocal() as db:
|
| 424 |
+
candidate = candidate_service.get_candidate(db, candidate_id)
|
| 425 |
+
if candidate is None:
|
| 426 |
+
raise HTTPException(status_code=404, detail="Candidate not found.")
|
| 427 |
+
payload_json = candidate_service.build_profile_payload(candidate)
|
| 428 |
+
|
| 429 |
+
return {
|
| 430 |
+
"status": "success",
|
| 431 |
+
"candidate_id": candidate.external_id,
|
| 432 |
+
"profile": payload_json,
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
if __name__ == "__main__":
|
| 437 |
+
import uvicorn
|
| 438 |
+
|
| 439 |
+
uvicorn.run("main:app", host=settings.host, port=settings.port, reload=False)
|
backend/mcp_server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MCP server package exposing recruitment tools."""
|
backend/mcp_server/router.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modular MCP tool router for the recruitment backend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Callable
|
| 6 |
+
|
| 7 |
+
from fastmcp import FastMCP
|
| 8 |
+
|
| 9 |
+
from mcp_server.tools.application_status import register as register_application_status
|
| 10 |
+
from mcp_server.tools.candidate_query import register as register_candidate_query
|
| 11 |
+
from mcp_server.tools.email_compose import register as register_email_compose
|
| 12 |
+
from mcp_server.tools.interview_records import register as register_interview_records
|
| 13 |
+
from mcp_server.tools.job_posting import register as register_job_posting
|
| 14 |
+
from mcp_server.tools.job_matching import register as register_job_matching
|
| 15 |
+
from mcp_server.tools.pdf_ingest import register as register_pdf_ingest
|
| 16 |
+
from mcp_server.tools.policy_info import register as register_policy_info
|
| 17 |
+
from mcp_server.tools.reference_resumes import register as register_reference_resumes
|
| 18 |
+
from mcp_server.tools.vector_search import register as register_vector_search
|
| 19 |
+
|
| 20 |
+
Registrar = Callable[[FastMCP], None]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _safe_register(mcp: FastMCP, registrar: Registrar, name: str) -> None:
|
| 24 |
+
try:
|
| 25 |
+
registrar(mcp)
|
| 26 |
+
except Exception as exc: # pragma: no cover
|
| 27 |
+
print(f"Failed to register {name}: {exc}")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def register_tools(mcp: FastMCP) -> None:
|
| 31 |
+
for name, registrar in (
|
| 32 |
+
("ingest_resume_pdf", register_pdf_ingest),
|
| 33 |
+
("semantic_candidate_search", register_vector_search),
|
| 34 |
+
("candidate_metadata_query", register_candidate_query),
|
| 35 |
+
("compute_job_match_score", register_job_matching),
|
| 36 |
+
("get_policy_info", register_policy_info),
|
| 37 |
+
("manage_application_status", register_application_status),
|
| 38 |
+
("manage_interview_records", register_interview_records),
|
| 39 |
+
("generate_job_posting", register_job_posting),
|
| 40 |
+
("bulk_ingest_reference_resumes", register_reference_resumes),
|
| 41 |
+
("email_compose", register_email_compose),
|
| 42 |
+
):
|
| 43 |
+
_safe_register(mcp, registrar, name)
|
backend/mcp_server/server.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastmcp import FastMCP
|
| 6 |
+
from mcp.server.sse import SseServerTransport
|
| 7 |
+
from starlette.responses import Response
|
| 8 |
+
from starlette.applications import Starlette
|
| 9 |
+
import uvicorn
|
| 10 |
+
from starlette.routing import Mount, Route
|
| 11 |
+
|
| 12 |
+
from core.config import get_settings
|
| 13 |
+
from mcp_server.router import register_tools
|
| 14 |
+
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
|
| 17 |
+
mcp = FastMCP("RecruitmentMCP")
|
| 18 |
+
register_tools(mcp)
|
| 19 |
+
|
| 20 |
+
transport = SseServerTransport("/messages/")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def sse_endpoint(request):
|
| 24 |
+
async with transport.connect_sse(request.scope, request.receive, request._send) as streams:
|
| 25 |
+
await mcp._mcp_server.run(
|
| 26 |
+
streams[0],
|
| 27 |
+
streams[1],
|
| 28 |
+
mcp._mcp_server.create_initialization_options(),
|
| 29 |
+
)
|
| 30 |
+
# Return a concrete response after client disconnect to avoid NoneType route errors.
|
| 31 |
+
return Response(status_code=204)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
mcp_app = Starlette(
|
| 35 |
+
routes=[
|
| 36 |
+
Route("/sse", endpoint=sse_endpoint),
|
| 37 |
+
Mount("/messages/", app=transport.handle_post_message),
|
| 38 |
+
]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
app = FastAPI(title="Recruitment MCP Server")
|
| 42 |
+
app.mount("/mcp-server", mcp_app)
|
| 43 |
+
|
| 44 |
+
for target in (app, mcp_app):
|
| 45 |
+
target.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=settings.cors_origin_list,
|
| 48 |
+
allow_credentials=True,
|
| 49 |
+
allow_methods=["*"],
|
| 50 |
+
allow_headers=["*"],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.get("/mcp-health")
|
| 55 |
+
def mcp_health() -> dict:
|
| 56 |
+
return {
|
| 57 |
+
"ok": True,
|
| 58 |
+
"mcp_endpoint": "/mcp-server/sse",
|
| 59 |
+
"tools": [
|
| 60 |
+
"ingest_resume_pdf",
|
| 61 |
+
"semantic_candidate_search",
|
| 62 |
+
"candidate_metadata_query",
|
| 63 |
+
"compute_job_match_score",
|
| 64 |
+
"get_policy_info",
|
| 65 |
+
"manage_application_status",
|
| 66 |
+
"manage_interview_records",
|
| 67 |
+
"generate_job_posting",
|
| 68 |
+
"bulk_ingest_reference_resumes",
|
| 69 |
+
],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
uvicorn.run("mcp_server.server:app", host=settings.host, port=9000, reload=False)
|
backend/mcp_server/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Recruitment MCP tool modules."""
|
backend/mcp_server/tools/application_status.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
|
| 6 |
+
from core.db import SessionLocal
|
| 7 |
+
from core.models import ApplicationStatus
|
| 8 |
+
|
| 9 |
+
DEFAULT_STAGES = [
|
| 10 |
+
"Applied",
|
| 11 |
+
"Screening",
|
| 12 |
+
"Shortlisted",
|
| 13 |
+
"Interview Scheduled",
|
| 14 |
+
"Technical Round",
|
| 15 |
+
"Final Interview",
|
| 16 |
+
"Offered",
|
| 17 |
+
"Hired",
|
| 18 |
+
"Rejected",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def register(mcp: FastMCP) -> None:
|
| 23 |
+
@mcp.tool()
|
| 24 |
+
def manage_application_status(
|
| 25 |
+
mode: str,
|
| 26 |
+
candidate_id: str | None = None,
|
| 27 |
+
candidate_name: str | None = None,
|
| 28 |
+
role_applied: str | None = None,
|
| 29 |
+
stage: str | None = None,
|
| 30 |
+
status: str | None = None,
|
| 31 |
+
notes: str | None = None,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""Create, update, and retrieve application statuses for candidates."""
|
| 34 |
+
normalized_mode = mode.strip().lower()
|
| 35 |
+
|
| 36 |
+
with SessionLocal() as db:
|
| 37 |
+
if normalized_mode in {"upsert", "update"}:
|
| 38 |
+
if not candidate_id:
|
| 39 |
+
return {"status": "error", "message": "candidate_id is required for upsert."}
|
| 40 |
+
|
| 41 |
+
existing = db.execute(
|
| 42 |
+
select(ApplicationStatus).where(ApplicationStatus.candidate_id == candidate_id)
|
| 43 |
+
).scalar_one_or_none()
|
| 44 |
+
|
| 45 |
+
if existing is None:
|
| 46 |
+
existing = ApplicationStatus(candidate_id=candidate_id)
|
| 47 |
+
db.add(existing)
|
| 48 |
+
|
| 49 |
+
if candidate_name is not None:
|
| 50 |
+
existing.candidate_name = candidate_name
|
| 51 |
+
if role_applied is not None:
|
| 52 |
+
existing.role_applied = role_applied
|
| 53 |
+
if stage is not None:
|
| 54 |
+
existing.stage = stage
|
| 55 |
+
if status is not None:
|
| 56 |
+
existing.status = status
|
| 57 |
+
if notes is not None:
|
| 58 |
+
existing.notes = notes
|
| 59 |
+
|
| 60 |
+
db.commit()
|
| 61 |
+
db.refresh(existing)
|
| 62 |
+
return {
|
| 63 |
+
"status": "success",
|
| 64 |
+
"message": "Application status upserted.",
|
| 65 |
+
"record": {
|
| 66 |
+
"candidate_id": existing.candidate_id,
|
| 67 |
+
"candidate_name": existing.candidate_name,
|
| 68 |
+
"role_applied": existing.role_applied,
|
| 69 |
+
"stage": existing.stage,
|
| 70 |
+
"status_text": existing.status,
|
| 71 |
+
"notes": existing.notes,
|
| 72 |
+
},
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if normalized_mode == "advance":
|
| 76 |
+
if not candidate_id:
|
| 77 |
+
return {"status": "error", "message": "candidate_id is required to advance stage."}
|
| 78 |
+
|
| 79 |
+
existing = db.execute(
|
| 80 |
+
select(ApplicationStatus).where(ApplicationStatus.candidate_id == candidate_id)
|
| 81 |
+
).scalar_one_or_none()
|
| 82 |
+
if existing is None:
|
| 83 |
+
return {"status": "not_found", "message": "Candidate application not found."}
|
| 84 |
+
|
| 85 |
+
current_stage = existing.stage or "Applied"
|
| 86 |
+
if current_stage not in DEFAULT_STAGES:
|
| 87 |
+
existing.stage = "Applied"
|
| 88 |
+
else:
|
| 89 |
+
idx = DEFAULT_STAGES.index(current_stage)
|
| 90 |
+
next_idx = min(idx + 1, len(DEFAULT_STAGES) - 1)
|
| 91 |
+
existing.stage = DEFAULT_STAGES[next_idx]
|
| 92 |
+
|
| 93 |
+
if notes:
|
| 94 |
+
existing.notes = notes
|
| 95 |
+
|
| 96 |
+
db.commit()
|
| 97 |
+
db.refresh(existing)
|
| 98 |
+
return {
|
| 99 |
+
"status": "success",
|
| 100 |
+
"message": "Application stage advanced.",
|
| 101 |
+
"record": {
|
| 102 |
+
"candidate_id": existing.candidate_id,
|
| 103 |
+
"stage": existing.stage,
|
| 104 |
+
"status_text": existing.status,
|
| 105 |
+
"notes": existing.notes,
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
if normalized_mode == "get":
|
| 110 |
+
if not candidate_id:
|
| 111 |
+
return {"status": "error", "message": "candidate_id is required for get."}
|
| 112 |
+
|
| 113 |
+
existing = db.execute(
|
| 114 |
+
select(ApplicationStatus).where(ApplicationStatus.candidate_id == candidate_id)
|
| 115 |
+
).scalar_one_or_none()
|
| 116 |
+
if existing is None:
|
| 117 |
+
return {"status": "not_found", "message": "Candidate application not found."}
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"status": "success",
|
| 121 |
+
"record": {
|
| 122 |
+
"candidate_id": existing.candidate_id,
|
| 123 |
+
"candidate_name": existing.candidate_name,
|
| 124 |
+
"role_applied": existing.role_applied,
|
| 125 |
+
"stage": existing.stage,
|
| 126 |
+
"status_text": existing.status,
|
| 127 |
+
"notes": existing.notes,
|
| 128 |
+
},
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if normalized_mode == "list":
|
| 132 |
+
rows = db.execute(
|
| 133 |
+
select(ApplicationStatus).order_by(ApplicationStatus.updated_at.desc()).limit(50)
|
| 134 |
+
).scalars().all()
|
| 135 |
+
|
| 136 |
+
records = [
|
| 137 |
+
{
|
| 138 |
+
"candidate_id": row.candidate_id,
|
| 139 |
+
"candidate_name": row.candidate_name,
|
| 140 |
+
"role_applied": row.role_applied,
|
| 141 |
+
"stage": row.stage,
|
| 142 |
+
"status_text": row.status,
|
| 143 |
+
}
|
| 144 |
+
for row in rows
|
| 145 |
+
]
|
| 146 |
+
return {
|
| 147 |
+
"status": "success",
|
| 148 |
+
"count": len(records),
|
| 149 |
+
"records": records,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"status": "error",
|
| 154 |
+
"message": "Unsupported mode. Use upsert, update, advance, get, or list.",
|
| 155 |
+
}
|
backend/mcp_server/tools/candidate_query.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
from core.db import SessionLocal
|
| 6 |
+
from services.candidate_service import CandidateService
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def register(mcp: FastMCP) -> None:
|
| 10 |
+
candidate_service = CandidateService()
|
| 11 |
+
|
| 12 |
+
@mcp.tool()
|
| 13 |
+
def candidate_metadata_query(query: str, limit: int = 10) -> dict:
|
| 14 |
+
"""Query candidate metadata from SQLite using keyword matching."""
|
| 15 |
+
with SessionLocal() as db:
|
| 16 |
+
rows = candidate_service.search_candidates(db=db, query=query, limit=limit)
|
| 17 |
+
|
| 18 |
+
if not rows:
|
| 19 |
+
return {
|
| 20 |
+
"status": "no_results",
|
| 21 |
+
"message": "No metadata matches found in SQLite.",
|
| 22 |
+
"results": [],
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
ui_payload = candidate_service.build_genui_payload(query=query, candidates=rows)
|
| 26 |
+
return {
|
| 27 |
+
"status": "success",
|
| 28 |
+
"count": len(ui_payload.get("cards") or []),
|
| 29 |
+
"results": ui_payload.get("cards") or [],
|
| 30 |
+
"ui": ui_payload,
|
| 31 |
+
}
|
backend/mcp_server/tools/email_compose.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Email drafting + sending MCP tool.
|
| 2 |
+
|
| 3 |
+
Two modes:
|
| 4 |
+
* ``mode="draft"`` — given a free-form natural-language brief, returns three
|
| 5 |
+
tonal variants (formal / casual / polite) that the user can pick from.
|
| 6 |
+
* ``mode="send"`` — given a final subject + body + recipient(s), delivers the
|
| 7 |
+
email through the configured SMTP relay (reusing the same settings as the
|
| 8 |
+
interview-invite flow).
|
| 9 |
+
|
| 10 |
+
The 3 tone variants are produced by Gemini using the project's ``GEMINI_API_KEY``.
|
| 11 |
+
If Gemini isn't reachable we fall back to deterministic template wrappers so the
|
| 12 |
+
flow still works for demos.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
from fastmcp import FastMCP
|
| 21 |
+
|
| 22 |
+
from core.config import get_settings
|
| 23 |
+
from services.notification_service import send_plain_email
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
_TONES: tuple[tuple[str, str], ...] = (
|
| 28 |
+
("formal", "Polished, business-formal tone. No contractions. Address the recipient with their full name or title."),
|
| 29 |
+
("casual", "Warm, conversational tone. First-name basis. Light contractions allowed but stay professional."),
|
| 30 |
+
("polite", "Courteous, considerate, and explicit about respect for the recipient's time. Slightly more formal than casual but warmer than 'formal'."),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _strip_code_fences(text: str) -> str:
|
| 35 |
+
fence = re.search(r"```(?:json)?\s*(\{[\s\S]*\})\s*```", text)
|
| 36 |
+
if fence:
|
| 37 |
+
return fence.group(1)
|
| 38 |
+
return text.strip()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _gemini_draft(brief: str, tone_label: str, tone_guide: str, recipient_name: str | None) -> dict:
|
| 42 |
+
"""Ask Gemini to produce a single subject + body for the given tone."""
|
| 43 |
+
settings = get_settings()
|
| 44 |
+
if not settings.gemini_api_key:
|
| 45 |
+
raise RuntimeError("GEMINI_API_KEY not configured.")
|
| 46 |
+
|
| 47 |
+
from google import genai
|
| 48 |
+
|
| 49 |
+
client = genai.Client(api_key=settings.gemini_api_key)
|
| 50 |
+
|
| 51 |
+
addressee = recipient_name or "the recipient"
|
| 52 |
+
prompt = (
|
| 53 |
+
f"You are drafting a professional email on behalf of a recruiter. Use the "
|
| 54 |
+
f"{tone_label} tone described below.\n\n"
|
| 55 |
+
f"TONE GUIDE: {tone_guide}\n\n"
|
| 56 |
+
f"USER BRIEF (what the email is about):\n{brief}\n\n"
|
| 57 |
+
f"Addressee placeholder: {addressee}\n\n"
|
| 58 |
+
"Return strictly valid JSON with two keys:\n"
|
| 59 |
+
' {"subject": "...", "body": "..."}\n'
|
| 60 |
+
"The body must end with a sign-off (best regards / thanks / etc.) on its own line."
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
response = client.models.generate_content(
|
| 64 |
+
model=settings.gemini_model or "gemini-2.5-pro",
|
| 65 |
+
contents=[prompt],
|
| 66 |
+
)
|
| 67 |
+
raw_text = (getattr(response, "text", None) or "").strip()
|
| 68 |
+
if not raw_text:
|
| 69 |
+
raise RuntimeError("Gemini returned an empty response.")
|
| 70 |
+
|
| 71 |
+
cleaned = _strip_code_fences(raw_text)
|
| 72 |
+
data = json.loads(cleaned)
|
| 73 |
+
if not isinstance(data, dict) or "subject" not in data or "body" not in data:
|
| 74 |
+
raise RuntimeError("Gemini response missing subject/body keys.")
|
| 75 |
+
return {"subject": str(data["subject"]).strip(), "body": str(data["body"]).strip()}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _fallback_draft(brief: str, tone_label: str, recipient_name: str | None) -> dict:
|
| 79 |
+
name = recipient_name or "there"
|
| 80 |
+
if tone_label == "formal":
|
| 81 |
+
opener = f"Dear {name},"
|
| 82 |
+
closer = "Best regards,\nRecruitment OS"
|
| 83 |
+
elif tone_label == "casual":
|
| 84 |
+
opener = f"Hi {name.split()[0] if recipient_name else 'there'},"
|
| 85 |
+
closer = "Cheers,\nRecruitment OS"
|
| 86 |
+
else:
|
| 87 |
+
opener = f"Hello {name},"
|
| 88 |
+
closer = "Thank you for your time,\nRecruitment OS"
|
| 89 |
+
subject = brief.strip().split("\n")[0][:90] or "Following up"
|
| 90 |
+
body = f"{opener}\n\n{brief.strip()}\n\n{closer}"
|
| 91 |
+
return {"subject": subject, "body": body}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _build_drafts(brief: str, recipient_name: str | None) -> list[dict]:
|
| 95 |
+
drafts: list[dict] = []
|
| 96 |
+
for tone_label, tone_guide in _TONES:
|
| 97 |
+
try:
|
| 98 |
+
drafted = _gemini_draft(brief, tone_label, tone_guide, recipient_name)
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
logger.warning("Gemini email draft failed for tone=%s: %s", tone_label, exc)
|
| 101 |
+
drafted = _fallback_draft(brief, tone_label, recipient_name)
|
| 102 |
+
drafts.append(
|
| 103 |
+
{
|
| 104 |
+
"tone": tone_label,
|
| 105 |
+
"subject": drafted["subject"],
|
| 106 |
+
"body": drafted["body"],
|
| 107 |
+
}
|
| 108 |
+
)
|
| 109 |
+
return drafts
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def register(mcp: FastMCP) -> None:
|
| 113 |
+
@mcp.tool()
|
| 114 |
+
def email_compose(
|
| 115 |
+
mode: str,
|
| 116 |
+
brief: str | None = None,
|
| 117 |
+
recipient_name: str | None = None,
|
| 118 |
+
recipient_email: str | None = None,
|
| 119 |
+
cc: str | None = None,
|
| 120 |
+
subject: str | None = None,
|
| 121 |
+
body: str | None = None,
|
| 122 |
+
tone: str | None = None,
|
| 123 |
+
) -> dict:
|
| 124 |
+
"""Draft or send recruiter emails.
|
| 125 |
+
|
| 126 |
+
``mode="draft"`` requires ``brief``. Returns three tone variants
|
| 127 |
+
(formal / casual / polite) that the user can choose from.
|
| 128 |
+
|
| 129 |
+
``mode="send"`` requires ``recipient_email`` (comma-separated allowed),
|
| 130 |
+
``subject`` and ``body`` and delivers the message via SMTP.
|
| 131 |
+
"""
|
| 132 |
+
normalized = (mode or "").strip().lower()
|
| 133 |
+
|
| 134 |
+
if normalized == "draft":
|
| 135 |
+
text = (brief or "").strip()
|
| 136 |
+
if not text:
|
| 137 |
+
return {
|
| 138 |
+
"status": "error",
|
| 139 |
+
"message": "brief is required when mode='draft'.",
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
drafts = _build_drafts(text, recipient_name)
|
| 143 |
+
|
| 144 |
+
cards = []
|
| 145 |
+
for draft in drafts:
|
| 146 |
+
preview = draft["body"][:200] + ("…" if len(draft["body"]) > 200 else "")
|
| 147 |
+
cards.append(
|
| 148 |
+
{
|
| 149 |
+
"title": f"{draft['tone'].title()} draft",
|
| 150 |
+
"subtitle": f"Subject: {draft['subject']}",
|
| 151 |
+
"tags": [draft["tone"].title()],
|
| 152 |
+
"meta": {"preview": preview},
|
| 153 |
+
"actions": [
|
| 154 |
+
{
|
| 155 |
+
"label": f"Use {draft['tone']} tone",
|
| 156 |
+
"action": (
|
| 157 |
+
f"Use the {draft['tone']} tone draft. "
|
| 158 |
+
"What's the recipient's email address?"
|
| 159 |
+
),
|
| 160 |
+
}
|
| 161 |
+
],
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
markdown_sections = [f"# Email drafts for: {text[:90]}\n"]
|
| 166 |
+
for draft in drafts:
|
| 167 |
+
markdown_sections.append(
|
| 168 |
+
f"## {draft['tone'].title()} tone\n"
|
| 169 |
+
f"**Subject:** {draft['subject']}\n\n"
|
| 170 |
+
f"{draft['body']}\n"
|
| 171 |
+
)
|
| 172 |
+
markdown_body = "\n---\n".join(markdown_sections)
|
| 173 |
+
|
| 174 |
+
return {
|
| 175 |
+
"status": "success",
|
| 176 |
+
"drafts": drafts,
|
| 177 |
+
"ui": {
|
| 178 |
+
"summary": (
|
| 179 |
+
"Three tone variants ready. Reply with your choice — "
|
| 180 |
+
"'use formal', 'use casual', or 'use polite' — and the "
|
| 181 |
+
"recipient email to send."
|
| 182 |
+
),
|
| 183 |
+
"cards": cards,
|
| 184 |
+
"markdown": markdown_body,
|
| 185 |
+
},
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
if normalized == "send":
|
| 189 |
+
if not recipient_email or not subject or not body:
|
| 190 |
+
return {
|
| 191 |
+
"status": "error",
|
| 192 |
+
"message": "recipient_email, subject, and body are required when mode='send'.",
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
to_list = [addr.strip() for addr in recipient_email.split(",") if addr.strip()]
|
| 196 |
+
cc_list = [addr.strip() for addr in (cc or "").split(",") if addr.strip()]
|
| 197 |
+
|
| 198 |
+
result = send_plain_email(
|
| 199 |
+
to=to_list,
|
| 200 |
+
subject=subject,
|
| 201 |
+
body=body,
|
| 202 |
+
cc=cc_list,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
tone_label = (tone or "selected").lower()
|
| 206 |
+
return {
|
| 207 |
+
"status": "success" if result.sent else "error",
|
| 208 |
+
"delivery": result.delivery,
|
| 209 |
+
"message": result.detail,
|
| 210 |
+
"ui": {
|
| 211 |
+
"summary": (
|
| 212 |
+
f"Email ({tone_label} tone) {'sent' if result.sent else 'NOT sent'} — "
|
| 213 |
+
f"{result.detail}"
|
| 214 |
+
),
|
| 215 |
+
"cards": [
|
| 216 |
+
{
|
| 217 |
+
"title": "Email delivery",
|
| 218 |
+
"subtitle": result.detail,
|
| 219 |
+
"tags": [
|
| 220 |
+
tone_label.title() or "Email",
|
| 221 |
+
"Delivered" if result.sent else "Failed",
|
| 222 |
+
],
|
| 223 |
+
"meta": {
|
| 224 |
+
"to": ", ".join(to_list),
|
| 225 |
+
"cc": ", ".join(cc_list) if cc_list else "—",
|
| 226 |
+
"subject": subject,
|
| 227 |
+
},
|
| 228 |
+
"actions": [],
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"markdown": (
|
| 232 |
+
f"# {subject}\n\n"
|
| 233 |
+
f"**To:** {', '.join(to_list)} \n"
|
| 234 |
+
f"**Cc:** {', '.join(cc_list) if cc_list else '—'}\n\n"
|
| 235 |
+
f"{body}"
|
| 236 |
+
),
|
| 237 |
+
},
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
"status": "error",
|
| 242 |
+
"message": "mode must be 'draft' or 'send'.",
|
| 243 |
+
}
|
backend/mcp_server/tools/interview_records.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import urllib.parse
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
from fastmcp import FastMCP
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
|
| 10 |
+
from core.db import SessionLocal
|
| 11 |
+
from core.models import InterviewRecord
|
| 12 |
+
from services.notification_service import generate_meet_url, send_interview_invite
|
| 13 |
+
|
| 14 |
+
VALID_STATUSES = {"Scheduled", "Completed", "Cancelled", "Rescheduled"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _parse_datetime(date_str: str, time_str: str | None = None) -> datetime:
|
| 18 |
+
if not date_str:
|
| 19 |
+
raise ValueError("date is required.")
|
| 20 |
+
|
| 21 |
+
t = (time_str or "09:00").strip()
|
| 22 |
+
if any(token in t.lower() for token in ("am", "pm")):
|
| 23 |
+
return datetime.strptime(f"{date_str} {t}", "%Y-%m-%d %I:%M %p")
|
| 24 |
+
return datetime.strptime(f"{date_str} {t}", "%Y-%m-%d %H:%M")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _format_for_calendar(dt: datetime) -> str:
|
| 28 |
+
return dt.strftime("%Y%m%dT%H%M%S")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _build_invite_artifacts(
|
| 32 |
+
*,
|
| 33 |
+
candidate_name: str,
|
| 34 |
+
candidate_email: str,
|
| 35 |
+
interviewer_name: str,
|
| 36 |
+
interviewer_email: str,
|
| 37 |
+
interview_round: str,
|
| 38 |
+
scheduled_on: datetime,
|
| 39 |
+
end_time: datetime,
|
| 40 |
+
notes: str | None,
|
| 41 |
+
meet_url: str,
|
| 42 |
+
) -> dict:
|
| 43 |
+
"""Generate Google Calendar URL, ICS payload (with Jitsi link), and mailto fallback."""
|
| 44 |
+
title = f"{interview_round} interview: {candidate_name}"
|
| 45 |
+
description_lines = [
|
| 46 |
+
f"Candidate: {candidate_name} <{candidate_email}>",
|
| 47 |
+
f"Interviewer: {interviewer_name} <{interviewer_email}>",
|
| 48 |
+
f"Round: {interview_round}",
|
| 49 |
+
f"Join: {meet_url}",
|
| 50 |
+
]
|
| 51 |
+
if notes:
|
| 52 |
+
description_lines.append(f"Notes: {notes}")
|
| 53 |
+
description = "\n".join(description_lines)
|
| 54 |
+
|
| 55 |
+
start_str = _format_for_calendar(scheduled_on)
|
| 56 |
+
end_str = _format_for_calendar(end_time)
|
| 57 |
+
|
| 58 |
+
google_calendar_url = (
|
| 59 |
+
"https://calendar.google.com/calendar/render?"
|
| 60 |
+
+ urllib.parse.urlencode(
|
| 61 |
+
{
|
| 62 |
+
"action": "TEMPLATE",
|
| 63 |
+
"text": title,
|
| 64 |
+
"dates": f"{start_str}/{end_str}",
|
| 65 |
+
"details": description,
|
| 66 |
+
"location": meet_url,
|
| 67 |
+
"add": f"{candidate_email},{interviewer_email}",
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
ics_lines = [
|
| 73 |
+
"BEGIN:VCALENDAR",
|
| 74 |
+
"VERSION:2.0",
|
| 75 |
+
"PRODID:-//Recruitment OS//Interview//EN",
|
| 76 |
+
"METHOD:REQUEST",
|
| 77 |
+
"BEGIN:VEVENT",
|
| 78 |
+
f"UID:interview-{int(scheduled_on.timestamp())}@recruitment-os",
|
| 79 |
+
f"DTSTAMP:{_format_for_calendar(datetime.utcnow())}Z",
|
| 80 |
+
f"DTSTART:{start_str}",
|
| 81 |
+
f"DTEND:{end_str}",
|
| 82 |
+
f"SUMMARY:{title}",
|
| 83 |
+
f"DESCRIPTION:{description.replace(chr(10), chr(92) + 'n')}",
|
| 84 |
+
f"LOCATION:{meet_url}",
|
| 85 |
+
f"ORGANIZER;CN={interviewer_name}:mailto:{interviewer_email}",
|
| 86 |
+
f"ATTENDEE;CN={candidate_name};RSVP=TRUE:mailto:{candidate_email}",
|
| 87 |
+
f"ATTENDEE;CN={interviewer_name};RSVP=TRUE:mailto:{interviewer_email}",
|
| 88 |
+
"STATUS:CONFIRMED",
|
| 89 |
+
"END:VEVENT",
|
| 90 |
+
"END:VCALENDAR",
|
| 91 |
+
]
|
| 92 |
+
ics_content = "\r\n".join(ics_lines)
|
| 93 |
+
ics_data_url = "data:text/calendar;base64," + base64.b64encode(ics_content.encode("utf-8")).decode("ascii")
|
| 94 |
+
|
| 95 |
+
mail_body = (
|
| 96 |
+
f"Hi {candidate_name.split()[0] if candidate_name else 'there'},%0D%0A%0D%0A"
|
| 97 |
+
f"Your {interview_round} interview with {interviewer_name} is on "
|
| 98 |
+
f"{scheduled_on.strftime('%A, %d %B %Y')} at {scheduled_on.strftime('%I:%M %p')}.%0D%0A%0D%0A"
|
| 99 |
+
f"Join here: {urllib.parse.quote(meet_url)}%0D%0A%0D%0A"
|
| 100 |
+
"Reply to this email if you need to reschedule.%0D%0A%0D%0A"
|
| 101 |
+
"— Recruitment OS"
|
| 102 |
+
)
|
| 103 |
+
mailto_url = (
|
| 104 |
+
f"mailto:{candidate_email}?cc={urllib.parse.quote(interviewer_email)}"
|
| 105 |
+
f"&subject={urllib.parse.quote(title)}&body={mail_body}"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"google_calendar_url": google_calendar_url,
|
| 110 |
+
"ics_data_url": ics_data_url,
|
| 111 |
+
"ics_content": ics_content,
|
| 112 |
+
"mailto_url": mailto_url,
|
| 113 |
+
"meet_url": meet_url,
|
| 114 |
+
"title": title,
|
| 115 |
+
"description": description,
|
| 116 |
+
"starts_at": scheduled_on.isoformat(sep=" ", timespec="minutes"),
|
| 117 |
+
"ends_at": end_time.isoformat(sep=" ", timespec="minutes"),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _build_interview_c1(
|
| 122 |
+
invite: dict,
|
| 123 |
+
interview_id: int,
|
| 124 |
+
delivery_channel: str,
|
| 125 |
+
auto_sent: bool,
|
| 126 |
+
candidate_name: str,
|
| 127 |
+
candidate_email: str,
|
| 128 |
+
interviewer_name: str,
|
| 129 |
+
interviewer_email: str,
|
| 130 |
+
interview_round: str,
|
| 131 |
+
) -> str:
|
| 132 |
+
"""Build a Thesys C1 payload using <custom_markdown> (the schema-stable wrapper)."""
|
| 133 |
+
delivery_text = (
|
| 134 |
+
f"✅ Auto-emailed via {delivery_channel}. Invite, `.ics` file, and Google Meet "
|
| 135 |
+
"link were sent to both attendees."
|
| 136 |
+
if auto_sent
|
| 137 |
+
else (
|
| 138 |
+
"⚠️ SMTP is not configured yet, so no email was sent. Set `SMTP_HOST`, "
|
| 139 |
+
"`SMTP_USER`, `SMTP_PASSWORD`, and `SMTP_FROM` in `.env` to enable auto-send."
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
markdown_body = (
|
| 144 |
+
f"# {invite['title']}\n\n"
|
| 145 |
+
f"**When:** {invite['starts_at']} → {invite['ends_at']} \n"
|
| 146 |
+
f"**Interview ID:** `{interview_id}` \n"
|
| 147 |
+
f"**Round:** {interview_round}\n\n"
|
| 148 |
+
"### Attendees\n"
|
| 149 |
+
f"- **Candidate:** {candidate_name} ({candidate_email})\n"
|
| 150 |
+
f"- **Interviewer:** {interviewer_name} ({interviewer_email})\n\n"
|
| 151 |
+
"### Meeting\n"
|
| 152 |
+
f"[Join Google Meet]({invite['meet_url']})\n\n"
|
| 153 |
+
f"### Delivery\n{delivery_text}\n\n"
|
| 154 |
+
f"[Add to Google Calendar]({invite['google_calendar_url']}) · "
|
| 155 |
+
f"[Download .ics]({invite['ics_data_url']}) · "
|
| 156 |
+
f"[Email both attendees]({invite['mailto_url']})"
|
| 157 |
+
)
|
| 158 |
+
safe_md = (
|
| 159 |
+
markdown_body.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 160 |
+
)
|
| 161 |
+
return f"<content><custom_markdown>{safe_md}</custom_markdown></content>"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def register(mcp: FastMCP) -> None:
|
| 165 |
+
@mcp.tool()
|
| 166 |
+
def manage_interview_records(
|
| 167 |
+
mode: str,
|
| 168 |
+
interview_id: int | None = None,
|
| 169 |
+
candidate_id: str | None = None,
|
| 170 |
+
candidate_name: str | None = None,
|
| 171 |
+
candidate_email: str | None = None,
|
| 172 |
+
interviewer_name: str | None = None,
|
| 173 |
+
interviewer_email: str | None = None,
|
| 174 |
+
interview_round: str = "Screening",
|
| 175 |
+
date: str | None = None,
|
| 176 |
+
time: str | None = None,
|
| 177 |
+
duration_minutes: int = 60,
|
| 178 |
+
status: str | None = None,
|
| 179 |
+
meeting_link: str | None = None,
|
| 180 |
+
notes: str | None = None,
|
| 181 |
+
) -> dict:
|
| 182 |
+
"""Schedule, update, and list interview records in local SQLite."""
|
| 183 |
+
normalized_mode = mode.strip().lower()
|
| 184 |
+
|
| 185 |
+
with SessionLocal() as db:
|
| 186 |
+
if normalized_mode in {"schedule", "insert"}:
|
| 187 |
+
if not all([candidate_name, candidate_email, interviewer_name, interviewer_email, date]):
|
| 188 |
+
return {
|
| 189 |
+
"status": "error",
|
| 190 |
+
"message": "candidate_name, candidate_email, interviewer_name, interviewer_email, and date are required.",
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
scheduled_on = _parse_datetime(date, time)
|
| 194 |
+
end_time = scheduled_on + timedelta(minutes=max(15, duration_minutes))
|
| 195 |
+
|
| 196 |
+
meet_url = meeting_link or generate_meet_url()
|
| 197 |
+
|
| 198 |
+
record = InterviewRecord(
|
| 199 |
+
candidate_id=candidate_id,
|
| 200 |
+
candidate_name=candidate_name,
|
| 201 |
+
candidate_email=candidate_email,
|
| 202 |
+
interviewer_name=interviewer_name,
|
| 203 |
+
interviewer_email=interviewer_email,
|
| 204 |
+
interview_round=interview_round,
|
| 205 |
+
scheduled_on=scheduled_on,
|
| 206 |
+
end_time=end_time,
|
| 207 |
+
duration_minutes=max(15, duration_minutes),
|
| 208 |
+
status=status or "Scheduled",
|
| 209 |
+
meeting_link=meet_url,
|
| 210 |
+
notes=notes,
|
| 211 |
+
)
|
| 212 |
+
db.add(record)
|
| 213 |
+
db.commit()
|
| 214 |
+
db.refresh(record)
|
| 215 |
+
|
| 216 |
+
invite = _build_invite_artifacts(
|
| 217 |
+
candidate_name=candidate_name,
|
| 218 |
+
candidate_email=candidate_email,
|
| 219 |
+
interviewer_name=interviewer_name,
|
| 220 |
+
interviewer_email=interviewer_email,
|
| 221 |
+
interview_round=interview_round,
|
| 222 |
+
scheduled_on=scheduled_on,
|
| 223 |
+
end_time=end_time,
|
| 224 |
+
notes=notes,
|
| 225 |
+
meet_url=meet_url,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
send_result = send_interview_invite(
|
| 229 |
+
candidate_name=candidate_name,
|
| 230 |
+
candidate_email=candidate_email,
|
| 231 |
+
interviewer_name=interviewer_name,
|
| 232 |
+
interviewer_email=interviewer_email,
|
| 233 |
+
interview_round=interview_round,
|
| 234 |
+
scheduled_on=scheduled_on,
|
| 235 |
+
end_time=end_time,
|
| 236 |
+
notes=notes,
|
| 237 |
+
ics_content=invite["ics_content"],
|
| 238 |
+
meet_url=meet_url,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if send_result.sent:
|
| 242 |
+
delivery_line = (
|
| 243 |
+
f"Email + .ics + Google Meet link auto-sent to "
|
| 244 |
+
f"{', '.join(send_result.recipients)}."
|
| 245 |
+
)
|
| 246 |
+
else:
|
| 247 |
+
delivery_line = send_result.detail
|
| 248 |
+
|
| 249 |
+
summary = (
|
| 250 |
+
f"Scheduled **{interview_round}** interview for **{candidate_name}** with "
|
| 251 |
+
f"**{interviewer_name}** on {scheduled_on.strftime('%A, %d %B %Y at %I:%M %p')}. "
|
| 252 |
+
f"Meet link: {meet_url}\n\n{delivery_line}"
|
| 253 |
+
)
|
| 254 |
+
cards = [
|
| 255 |
+
{
|
| 256 |
+
"title": invite["title"],
|
| 257 |
+
"subtitle": f"{invite['starts_at']} → {invite['ends_at']}",
|
| 258 |
+
"tags": [interview_round, "Auto-emailed" if send_result.sent else "Manual send"],
|
| 259 |
+
"meta": {
|
| 260 |
+
"interview_id": record.id,
|
| 261 |
+
"candidate_email": candidate_email,
|
| 262 |
+
"interviewer_email": interviewer_email,
|
| 263 |
+
"meet_url": meet_url,
|
| 264 |
+
},
|
| 265 |
+
"actions": [
|
| 266 |
+
{"label": "Join meeting", "action": meet_url},
|
| 267 |
+
{"label": "Add to Google Calendar", "action": invite["google_calendar_url"]},
|
| 268 |
+
],
|
| 269 |
+
}
|
| 270 |
+
]
|
| 271 |
+
return {
|
| 272 |
+
"status": "success",
|
| 273 |
+
"message": "Interview scheduled successfully.",
|
| 274 |
+
"interview_id": record.id,
|
| 275 |
+
"invite": invite,
|
| 276 |
+
"delivery": {
|
| 277 |
+
"sent": send_result.sent,
|
| 278 |
+
"channel": send_result.delivery,
|
| 279 |
+
"detail": send_result.detail,
|
| 280 |
+
"recipients": send_result.recipients,
|
| 281 |
+
},
|
| 282 |
+
"ui": {
|
| 283 |
+
"summary": summary,
|
| 284 |
+
"cards": cards,
|
| 285 |
+
"c1_response": _build_interview_c1(
|
| 286 |
+
invite,
|
| 287 |
+
record.id,
|
| 288 |
+
send_result.delivery,
|
| 289 |
+
send_result.sent,
|
| 290 |
+
candidate_name,
|
| 291 |
+
candidate_email,
|
| 292 |
+
interviewer_name,
|
| 293 |
+
interviewer_email,
|
| 294 |
+
interview_round,
|
| 295 |
+
),
|
| 296 |
+
},
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if normalized_mode in {"reschedule", "update"}:
|
| 300 |
+
if not interview_id:
|
| 301 |
+
return {"status": "error", "message": "interview_id is required for update."}
|
| 302 |
+
|
| 303 |
+
record = db.execute(
|
| 304 |
+
select(InterviewRecord).where(InterviewRecord.id == interview_id)
|
| 305 |
+
).scalar_one_or_none()
|
| 306 |
+
if record is None:
|
| 307 |
+
return {"status": "not_found", "message": "Interview record not found."}
|
| 308 |
+
|
| 309 |
+
if date:
|
| 310 |
+
scheduled_on = _parse_datetime(date, time)
|
| 311 |
+
record.scheduled_on = scheduled_on
|
| 312 |
+
record.end_time = scheduled_on + timedelta(minutes=max(15, duration_minutes))
|
| 313 |
+
record.duration_minutes = max(15, duration_minutes)
|
| 314 |
+
|
| 315 |
+
if status:
|
| 316 |
+
if status not in VALID_STATUSES:
|
| 317 |
+
return {
|
| 318 |
+
"status": "error",
|
| 319 |
+
"message": f"Invalid status. Allowed: {', '.join(sorted(VALID_STATUSES))}",
|
| 320 |
+
}
|
| 321 |
+
record.status = status
|
| 322 |
+
|
| 323 |
+
if interviewer_name:
|
| 324 |
+
record.interviewer_name = interviewer_name
|
| 325 |
+
if interviewer_email:
|
| 326 |
+
record.interviewer_email = interviewer_email
|
| 327 |
+
if interview_round:
|
| 328 |
+
record.interview_round = interview_round
|
| 329 |
+
if meeting_link is not None:
|
| 330 |
+
record.meeting_link = meeting_link
|
| 331 |
+
if notes is not None:
|
| 332 |
+
record.notes = notes
|
| 333 |
+
|
| 334 |
+
db.commit()
|
| 335 |
+
db.refresh(record)
|
| 336 |
+
return {
|
| 337 |
+
"status": "success",
|
| 338 |
+
"message": "Interview updated successfully.",
|
| 339 |
+
"interview_id": record.id,
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
if normalized_mode == "get":
|
| 343 |
+
if not interview_id:
|
| 344 |
+
return {"status": "error", "message": "interview_id is required for get."}
|
| 345 |
+
|
| 346 |
+
record = db.execute(
|
| 347 |
+
select(InterviewRecord).where(InterviewRecord.id == interview_id)
|
| 348 |
+
).scalar_one_or_none()
|
| 349 |
+
if record is None:
|
| 350 |
+
return {"status": "not_found", "message": "Interview record not found."}
|
| 351 |
+
|
| 352 |
+
return {
|
| 353 |
+
"status": "success",
|
| 354 |
+
"record": {
|
| 355 |
+
"interview_id": record.id,
|
| 356 |
+
"candidate_name": record.candidate_name,
|
| 357 |
+
"candidate_email": record.candidate_email,
|
| 358 |
+
"interviewer_name": record.interviewer_name,
|
| 359 |
+
"interviewer_email": record.interviewer_email,
|
| 360 |
+
"interview_round": record.interview_round,
|
| 361 |
+
"scheduled_on": record.scheduled_on.isoformat(sep=" ", timespec="seconds"),
|
| 362 |
+
"end_time": record.end_time.isoformat(sep=" ", timespec="seconds"),
|
| 363 |
+
"status": record.status,
|
| 364 |
+
"meeting_link": record.meeting_link,
|
| 365 |
+
"notes": record.notes,
|
| 366 |
+
},
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
if normalized_mode == "list":
|
| 370 |
+
rows = db.execute(
|
| 371 |
+
select(InterviewRecord).order_by(InterviewRecord.scheduled_on.asc()).limit(100)
|
| 372 |
+
).scalars().all()
|
| 373 |
+
|
| 374 |
+
records = [
|
| 375 |
+
{
|
| 376 |
+
"interview_id": row.id,
|
| 377 |
+
"candidate_name": row.candidate_name,
|
| 378 |
+
"interviewer_name": row.interviewer_name,
|
| 379 |
+
"round": row.interview_round,
|
| 380 |
+
"scheduled_on": row.scheduled_on.isoformat(sep=" ", timespec="seconds"),
|
| 381 |
+
"status": row.status,
|
| 382 |
+
}
|
| 383 |
+
for row in rows
|
| 384 |
+
]
|
| 385 |
+
return {
|
| 386 |
+
"status": "success",
|
| 387 |
+
"count": len(records),
|
| 388 |
+
"records": records,
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
return {
|
| 392 |
+
"status": "error",
|
| 393 |
+
"message": "Unsupported mode. Use schedule, insert, reschedule, update, get, or list.",
|
| 394 |
+
}
|
backend/mcp_server/tools/job_matching.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
from services.search_service import QdrantSearchService
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _normalize_skills(skills_csv: str) -> set[str]:
|
| 9 |
+
return {token.strip().lower() for token in skills_csv.split(",") if token.strip()}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def register(mcp: FastMCP) -> None:
|
| 13 |
+
search_service = QdrantSearchService()
|
| 14 |
+
|
| 15 |
+
@mcp.tool()
|
| 16 |
+
def compute_job_match_score(
|
| 17 |
+
job_description: str,
|
| 18 |
+
required_skills: str = "",
|
| 19 |
+
min_years_experience: float = 0.0,
|
| 20 |
+
top_k: int = 5,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""Compute ranked candidate fit scores for a job description."""
|
| 23 |
+
rows = search_service.semantic_search(query=job_description, top_k=max(top_k * 2, 10))
|
| 24 |
+
if not rows:
|
| 25 |
+
return {
|
| 26 |
+
"status": "no_results",
|
| 27 |
+
"message": "No candidate vectors found for this job description.",
|
| 28 |
+
"matches": [],
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
required = _normalize_skills(required_skills)
|
| 32 |
+
scored = []
|
| 33 |
+
for row in rows:
|
| 34 |
+
semantic_score = float(row.get("score") or 0.0)
|
| 35 |
+
candidate_skills = _normalize_skills(str(row.get("skills") or ""))
|
| 36 |
+
skill_overlap = (
|
| 37 |
+
len(required.intersection(candidate_skills)) / len(required)
|
| 38 |
+
if required
|
| 39 |
+
else 1.0
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
years_exp = float(row.get("years_experience") or 0.0)
|
| 43 |
+
experience_factor = 1.0 if years_exp >= min_years_experience else 0.5
|
| 44 |
+
|
| 45 |
+
final_score = (semantic_score * 0.6) + (skill_overlap * 0.3) + (experience_factor * 0.1)
|
| 46 |
+
row_copy = dict(row)
|
| 47 |
+
row_copy["final_match_score"] = round(final_score, 4)
|
| 48 |
+
scored.append(row_copy)
|
| 49 |
+
|
| 50 |
+
scored.sort(key=lambda item: item["final_match_score"], reverse=True)
|
| 51 |
+
best = scored[:top_k]
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"status": "success",
|
| 55 |
+
"count": len(best),
|
| 56 |
+
"matches": best,
|
| 57 |
+
"ui": search_service.build_genui_payload(query=job_description, rows=best),
|
| 58 |
+
}
|
backend/mcp_server/tools/job_posting.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import time
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastmcp import FastMCP
|
| 10 |
+
|
| 11 |
+
JOB_POSTINGS_FILE = Path(__file__).resolve().parents[2] / "data" / "job_postings.json"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _parse_years(value: object) -> int:
|
| 15 |
+
"""Parse experience_years which may arrive as int or string ("5-8", "5+")."""
|
| 16 |
+
if isinstance(value, int):
|
| 17 |
+
return max(0, value)
|
| 18 |
+
if isinstance(value, float):
|
| 19 |
+
return max(0, int(value))
|
| 20 |
+
if isinstance(value, str):
|
| 21 |
+
match = re.search(r"\d+", value)
|
| 22 |
+
if match:
|
| 23 |
+
return max(0, int(match.group(0)))
|
| 24 |
+
return 0
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _build_posting_text(
|
| 28 |
+
*,
|
| 29 |
+
title: str,
|
| 30 |
+
company: str,
|
| 31 |
+
location: str,
|
| 32 |
+
employment_type: str,
|
| 33 |
+
mode: str,
|
| 34 |
+
years: int,
|
| 35 |
+
salary_range: str,
|
| 36 |
+
skills: list[str],
|
| 37 |
+
) -> str:
|
| 38 |
+
skill_lines = "\n".join(f"- {skill}" for skill in skills)
|
| 39 |
+
return (
|
| 40 |
+
f"# {title}\n\n"
|
| 41 |
+
f"**Company:** {company}\n"
|
| 42 |
+
f"**Location:** {location}\n"
|
| 43 |
+
f"**Type:** {employment_type} | {mode}\n"
|
| 44 |
+
f"**Experience:** {years}+ years\n"
|
| 45 |
+
f"**Salary:** {salary_range}\n\n"
|
| 46 |
+
"---\n\n"
|
| 47 |
+
"## About the Role\n\n"
|
| 48 |
+
f"We are hiring a {title} to join {company} in {location}. "
|
| 49 |
+
f"This {employment_type.lower()} {mode.lower()} role requires "
|
| 50 |
+
f"approximately {years}+ years of experience.\n\n"
|
| 51 |
+
"## Key Responsibilities\n\n"
|
| 52 |
+
"- Design, develop, and ship production-grade features\n"
|
| 53 |
+
"- Collaborate with product, design, and engineering stakeholders\n"
|
| 54 |
+
"- Write maintainable, testable, and well-documented code\n"
|
| 55 |
+
"- Drive code reviews, mentorship, and continuous improvement\n\n"
|
| 56 |
+
"## Required Skills\n\n"
|
| 57 |
+
f"{skill_lines}\n"
|
| 58 |
+
f"- {years}+ years of professional experience\n\n"
|
| 59 |
+
"## Selection Process\n\n"
|
| 60 |
+
"1. Resume screening\n"
|
| 61 |
+
"2. Technical interview\n"
|
| 62 |
+
"3. Hiring manager discussion\n"
|
| 63 |
+
"4. Offer\n"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _persist_posting(record: dict) -> None:
|
| 68 |
+
JOB_POSTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
existing: list[dict] = []
|
| 70 |
+
if JOB_POSTINGS_FILE.exists():
|
| 71 |
+
try:
|
| 72 |
+
existing = json.loads(JOB_POSTINGS_FILE.read_text(encoding="utf-8"))
|
| 73 |
+
if not isinstance(existing, list):
|
| 74 |
+
existing = []
|
| 75 |
+
except json.JSONDecodeError:
|
| 76 |
+
existing = []
|
| 77 |
+
existing.append(record)
|
| 78 |
+
JOB_POSTINGS_FILE.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def register(mcp: FastMCP) -> None:
|
| 82 |
+
@mcp.tool()
|
| 83 |
+
def generate_job_posting(
|
| 84 |
+
title: str,
|
| 85 |
+
location: str,
|
| 86 |
+
experience_years: int | str,
|
| 87 |
+
skills: str,
|
| 88 |
+
employment_type: str = "Full-time",
|
| 89 |
+
mode: str = "Hybrid",
|
| 90 |
+
salary_range: str = "Competitive",
|
| 91 |
+
company_name: str = "Recruitment OS",
|
| 92 |
+
) -> dict:
|
| 93 |
+
"""Draft a structured job posting and persist it under backend/data/job_postings.json.
|
| 94 |
+
|
| 95 |
+
Pass ``experience_years`` as an integer or a string range like "5-8" (the lower
|
| 96 |
+
bound is used). ``skills`` is a comma-separated string.
|
| 97 |
+
"""
|
| 98 |
+
clean_title = (title or "").strip()
|
| 99 |
+
clean_location = (location or "").strip()
|
| 100 |
+
clean_skills = [token.strip() for token in (skills or "").split(",") if token.strip()]
|
| 101 |
+
years = _parse_years(experience_years)
|
| 102 |
+
|
| 103 |
+
if not clean_title or not clean_location or not clean_skills:
|
| 104 |
+
return {
|
| 105 |
+
"status": "error",
|
| 106 |
+
"message": "title, location and comma-separated skills are required.",
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
posting_text = _build_posting_text(
|
| 110 |
+
title=clean_title,
|
| 111 |
+
company=company_name,
|
| 112 |
+
location=clean_location,
|
| 113 |
+
employment_type=employment_type,
|
| 114 |
+
mode=mode,
|
| 115 |
+
years=years,
|
| 116 |
+
salary_range=salary_range,
|
| 117 |
+
skills=clean_skills,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
record = {
|
| 121 |
+
"job_id": int(time.time() * 1000),
|
| 122 |
+
"title": clean_title,
|
| 123 |
+
"company_name": company_name,
|
| 124 |
+
"location": clean_location,
|
| 125 |
+
"employment_type": employment_type,
|
| 126 |
+
"work_mode": mode,
|
| 127 |
+
"experience_years": years,
|
| 128 |
+
"salary_range": salary_range,
|
| 129 |
+
"skills": clean_skills,
|
| 130 |
+
"posting_text": posting_text,
|
| 131 |
+
"posted_on": datetime.now(timezone.utc).isoformat(),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
_persist_posting(record)
|
| 135 |
+
|
| 136 |
+
summary = (
|
| 137 |
+
f"Drafted **{clean_title}** ({employment_type} · {mode}) in {clean_location}. "
|
| 138 |
+
f"Saved as job id `{record['job_id']}`."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
skill_chart_data = [
|
| 142 |
+
{"name": skill, "weight": max(1, len(clean_skills) - idx)}
|
| 143 |
+
for idx, skill in enumerate(clean_skills)
|
| 144 |
+
]
|
| 145 |
+
skill_chart = {
|
| 146 |
+
"type": "bar",
|
| 147 |
+
"title": "Skill weighting",
|
| 148 |
+
"xKey": "name",
|
| 149 |
+
"yKey": "weight",
|
| 150 |
+
"data": skill_chart_data,
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
skills_chips = " ".join(f"`{skill}`" for skill in clean_skills)
|
| 154 |
+
markdown_body = (
|
| 155 |
+
f"# {clean_title}\n\n"
|
| 156 |
+
f"**Company:** {company_name} \n"
|
| 157 |
+
f"**Location:** {clean_location} \n"
|
| 158 |
+
f"**Type:** {employment_type} · {mode} \n"
|
| 159 |
+
f"**Experience:** {years}+ years \n"
|
| 160 |
+
f"**Salary:** {salary_range} \n"
|
| 161 |
+
f"**Job ID:** `{record['job_id']}`\n\n"
|
| 162 |
+
"### Required skills\n"
|
| 163 |
+
f"{skills_chips}\n\n"
|
| 164 |
+
"### Responsibilities\n"
|
| 165 |
+
"- Design, develop, and ship production-grade features\n"
|
| 166 |
+
"- Collaborate with product, design, and engineering stakeholders\n"
|
| 167 |
+
"- Write maintainable, testable, and well-documented code\n"
|
| 168 |
+
"- Drive code reviews, mentorship, and continuous improvement\n\n"
|
| 169 |
+
"### Selection process\n"
|
| 170 |
+
"1. Resume screening\n"
|
| 171 |
+
"2. Technical interview\n"
|
| 172 |
+
"3. Hiring manager discussion\n"
|
| 173 |
+
"4. Offer\n"
|
| 174 |
+
)
|
| 175 |
+
safe_md = (
|
| 176 |
+
markdown_body.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 177 |
+
)
|
| 178 |
+
c1_response = f"<content><custom_markdown>{safe_md}</custom_markdown></content>"
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
"status": "success",
|
| 182 |
+
"job_posting": record,
|
| 183 |
+
"ui": {
|
| 184 |
+
"summary": summary,
|
| 185 |
+
"cards": [
|
| 186 |
+
{
|
| 187 |
+
"title": clean_title,
|
| 188 |
+
"subtitle": f"{employment_type} | {mode} | {clean_location}",
|
| 189 |
+
"tags": clean_skills[:6],
|
| 190 |
+
"meta": {
|
| 191 |
+
"experience_years": years,
|
| 192 |
+
"salary_range": salary_range,
|
| 193 |
+
},
|
| 194 |
+
"actions": [],
|
| 195 |
+
}
|
| 196 |
+
],
|
| 197 |
+
"chart": skill_chart,
|
| 198 |
+
"markdown": posting_text,
|
| 199 |
+
"c1_response": c1_response,
|
| 200 |
+
},
|
| 201 |
+
"posting_text": posting_text,
|
| 202 |
+
}
|
backend/mcp_server/tools/pdf_ingest.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from core.db import SessionLocal
|
| 8 |
+
from services.ingestion_service import IngestionService
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def register(mcp: FastMCP) -> None:
|
| 12 |
+
ingestion_service = IngestionService()
|
| 13 |
+
|
| 14 |
+
@mcp.tool()
|
| 15 |
+
def ingest_resume_pdf(path: str) -> dict:
|
| 16 |
+
"""Ingest a local PDF file into SQLite metadata and Qdrant vectors."""
|
| 17 |
+
file_path = Path(path).expanduser().resolve()
|
| 18 |
+
if not file_path.exists():
|
| 19 |
+
return {"status": "error", "message": f"File does not exist: {file_path}"}
|
| 20 |
+
|
| 21 |
+
if file_path.suffix.lower() != ".pdf":
|
| 22 |
+
return {"status": "error", "message": "Only PDF files are supported."}
|
| 23 |
+
|
| 24 |
+
data = file_path.read_bytes()
|
| 25 |
+
|
| 26 |
+
with SessionLocal() as db:
|
| 27 |
+
result = ingestion_service.ingest_pdf(
|
| 28 |
+
db,
|
| 29 |
+
file_name=file_path.name,
|
| 30 |
+
file_bytes=data,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
"status": "success",
|
| 35 |
+
"candidate_id": result.candidate_id,
|
| 36 |
+
"file_name": result.file_name,
|
| 37 |
+
"chunks_indexed": result.chunks_indexed,
|
| 38 |
+
"r2_url": result.r2_url,
|
| 39 |
+
"message": "Resume ingested successfully.",
|
| 40 |
+
}
|
backend/mcp_server/tools/policy_info.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastmcp import FastMCP
|
| 7 |
+
|
| 8 |
+
POLICY_FILE = Path(__file__).resolve().parents[2] / "data" / "policies.json"
|
| 9 |
+
|
| 10 |
+
POLICY_ALIASES = {
|
| 11 |
+
"leave policy": ["leave", "vacation", "holiday", "attendance"],
|
| 12 |
+
"work from home policy": ["wfh", "remote", "hybrid", "home"],
|
| 13 |
+
"referral policy": ["referral", "employee referral", "bonus"],
|
| 14 |
+
"interview policy": ["interview", "panel", "feedback"],
|
| 15 |
+
"code of conduct": ["conduct", "ethics", "behavior", "harassment"],
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _load_policies() -> dict[str, str]:
|
| 20 |
+
if not POLICY_FILE.exists():
|
| 21 |
+
return {}
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
payload = json.loads(POLICY_FILE.read_text(encoding="utf-8"))
|
| 25 |
+
except Exception:
|
| 26 |
+
return {}
|
| 27 |
+
|
| 28 |
+
result: dict[str, str] = {}
|
| 29 |
+
for key, value in payload.items():
|
| 30 |
+
if isinstance(key, str) and isinstance(value, str):
|
| 31 |
+
result[key.strip().lower()] = value.strip()
|
| 32 |
+
return result
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
_GENERIC_QUERY_TOKENS = {
|
| 36 |
+
"all",
|
| 37 |
+
"any",
|
| 38 |
+
"company",
|
| 39 |
+
"company policies",
|
| 40 |
+
"company policy",
|
| 41 |
+
"check company policies",
|
| 42 |
+
"policies",
|
| 43 |
+
"policy",
|
| 44 |
+
"hr",
|
| 45 |
+
"hr policies",
|
| 46 |
+
"show me",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _wrap_markdown_as_c1(text: str) -> str:
|
| 51 |
+
safe = (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
| 52 |
+
return f"<content><custom_markdown>{safe}</custom_markdown></content>"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _build_policies_markdown(policies: dict[str, str]) -> str:
|
| 56 |
+
if not policies:
|
| 57 |
+
return "_No policies are currently configured._"
|
| 58 |
+
sections = ["# Company policies\n"]
|
| 59 |
+
for name, text in policies.items():
|
| 60 |
+
sections.append(f"### {name.title()}\n{text}\n")
|
| 61 |
+
return "\n".join(sections)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Visual icon + chart-weight per policy so the infographic conveys relative coverage.
|
| 65 |
+
_POLICY_META = {
|
| 66 |
+
"leave policy": {"icon": "🌴", "weight": 18, "tag": "Leave"},
|
| 67 |
+
"work from home policy": {"icon": "🏠", "weight": 12, "tag": "WFH"},
|
| 68 |
+
"referral policy": {"icon": "🎁", "weight": 8, "tag": "Referral"},
|
| 69 |
+
"interview policy": {"icon": "🎯", "weight": 10, "tag": "Interview"},
|
| 70 |
+
"code of conduct": {"icon": "🛡️", "weight": 14, "tag": "Conduct"},
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _slug(name: str) -> str:
|
| 75 |
+
return name.replace(" ", "-").lower()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _policy_meta(name: str) -> dict:
|
| 79 |
+
return _POLICY_META.get(name.lower(), {"icon": "📄", "weight": 6, "tag": name.title()[:18]})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _format_all_policies(policies: dict[str, str]) -> dict:
|
| 83 |
+
if not policies:
|
| 84 |
+
return {
|
| 85 |
+
"status": "not_found",
|
| 86 |
+
"message": "No policies are configured.",
|
| 87 |
+
"available_policies": [],
|
| 88 |
+
"ui": {
|
| 89 |
+
"summary": "No policies configured.",
|
| 90 |
+
"c1_response": _wrap_markdown_as_c1("_No policies are currently configured._"),
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
markdown = _build_policies_markdown(policies)
|
| 94 |
+
tabs = [
|
| 95 |
+
{
|
| 96 |
+
"id": _slug(name),
|
| 97 |
+
"label": f"{_policy_meta(name)['icon']} {name.title()}",
|
| 98 |
+
"icon": _policy_meta(name)["icon"],
|
| 99 |
+
"markdown": f"### {name.title()}\n\n{text}",
|
| 100 |
+
}
|
| 101 |
+
for name, text in policies.items()
|
| 102 |
+
]
|
| 103 |
+
chart_data = [
|
| 104 |
+
{"name": _policy_meta(name)["tag"], "value": _policy_meta(name)["weight"]}
|
| 105 |
+
for name in policies.keys()
|
| 106 |
+
]
|
| 107 |
+
return {
|
| 108 |
+
"status": "success",
|
| 109 |
+
"policy_name": "all",
|
| 110 |
+
"summary": "All currently configured company policies",
|
| 111 |
+
"policies": [
|
| 112 |
+
{"policy_name": name.title(), "policy_text": text}
|
| 113 |
+
for name, text in policies.items()
|
| 114 |
+
],
|
| 115 |
+
"ui": {
|
| 116 |
+
"summary": "Here is the company policy stack — switch tabs to drill into each.",
|
| 117 |
+
"c1_response": _wrap_markdown_as_c1(markdown),
|
| 118 |
+
"tabs": tabs,
|
| 119 |
+
"chart": {
|
| 120 |
+
"type": "donut",
|
| 121 |
+
"title": "Policy coverage at a glance",
|
| 122 |
+
"xKey": "name",
|
| 123 |
+
"yKey": "value",
|
| 124 |
+
"data": chart_data,
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _format_single_policy(name: str, text: str) -> dict:
|
| 131 |
+
markdown = f"# {name.title()}\n\n{text}"
|
| 132 |
+
return {
|
| 133 |
+
"status": "success",
|
| 134 |
+
"policy_name": name,
|
| 135 |
+
"policy_text": text,
|
| 136 |
+
"ui": {
|
| 137 |
+
"summary": f"{name.title()} summary",
|
| 138 |
+
"c1_response": _wrap_markdown_as_c1(markdown),
|
| 139 |
+
"cards": [
|
| 140 |
+
{
|
| 141 |
+
"title": name.title(),
|
| 142 |
+
"subtitle": text,
|
| 143 |
+
"tags": ["Policy"],
|
| 144 |
+
"actions": [],
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
},
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def register(mcp: FastMCP) -> None:
|
| 152 |
+
policies = _load_policies()
|
| 153 |
+
|
| 154 |
+
@mcp.tool()
|
| 155 |
+
def get_policy_info(policy_name: str) -> dict:
|
| 156 |
+
"""Lookup policy text by name or alias.
|
| 157 |
+
|
| 158 |
+
Pass ``policy_name="all"`` (or any generic phrase like "company policies") to
|
| 159 |
+
retrieve every configured policy at once.
|
| 160 |
+
"""
|
| 161 |
+
query = (policy_name or "").strip().lower()
|
| 162 |
+
if not query or query in _GENERIC_QUERY_TOKENS:
|
| 163 |
+
return _format_all_policies(policies)
|
| 164 |
+
|
| 165 |
+
# Generic phrases that contain "polic" without naming a specific area also map
|
| 166 |
+
# to the all-policies summary so recruiters get something useful.
|
| 167 |
+
if "polic" in query and not any(
|
| 168 |
+
keyword in query
|
| 169 |
+
for keyword in ("leave", "vacation", "wfh", "remote", "hybrid", "home", "referral", "bonus", "interview", "feedback", "conduct", "ethics", "harassment")
|
| 170 |
+
):
|
| 171 |
+
return _format_all_policies(policies)
|
| 172 |
+
|
| 173 |
+
for name, text in policies.items():
|
| 174 |
+
if query in name:
|
| 175 |
+
return _format_single_policy(name, text)
|
| 176 |
+
|
| 177 |
+
for canonical_name, aliases in POLICY_ALIASES.items():
|
| 178 |
+
if any(alias in query for alias in aliases):
|
| 179 |
+
text = policies.get(canonical_name)
|
| 180 |
+
if text:
|
| 181 |
+
return _format_single_policy(canonical_name, text)
|
| 182 |
+
|
| 183 |
+
available = sorted(policies.keys())
|
| 184 |
+
return {
|
| 185 |
+
"status": "not_found",
|
| 186 |
+
"message": f"No matching policy found for '{policy_name}'.",
|
| 187 |
+
"available_policies": available,
|
| 188 |
+
"ui": {
|
| 189 |
+
"summary": f"No policy matched '{policy_name}'. Available: {', '.join(available)}.",
|
| 190 |
+
"c1_response": _wrap_markdown_as_c1(
|
| 191 |
+
f"_No policy matched **{policy_name}**._\n\n"
|
| 192 |
+
f"**Available policies:** {', '.join(available) or '_(none configured)_'}"
|
| 193 |
+
),
|
| 194 |
+
},
|
| 195 |
+
}
|
backend/mcp_server/tools/reference_resumes.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
from services.reference_data_service import ingest_reference_resumes, sync_reference_resumes_to_local
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def register(mcp: FastMCP) -> None:
|
| 9 |
+
@mcp.tool()
|
| 10 |
+
def bulk_ingest_reference_resumes(
|
| 11 |
+
limit: int = 20,
|
| 12 |
+
source_dir: str | None = None,
|
| 13 |
+
force_reingest: bool = False,
|
| 14 |
+
sync_to_local: bool = True,
|
| 15 |
+
sync_max_files: int = 150,
|
| 16 |
+
) -> dict:
|
| 17 |
+
"""Bulk ingest sample resumes from a local directory into SQLite and vector index."""
|
| 18 |
+
sync_result = None
|
| 19 |
+
ingest_source = source_dir
|
| 20 |
+
|
| 21 |
+
if sync_to_local:
|
| 22 |
+
sync_result = sync_reference_resumes_to_local(
|
| 23 |
+
max_files=max(1, min(sync_max_files, 1000)),
|
| 24 |
+
source_dir=source_dir,
|
| 25 |
+
)
|
| 26 |
+
ingest_source = str(sync_result.get("target_dir"))
|
| 27 |
+
|
| 28 |
+
summary = ingest_reference_resumes(
|
| 29 |
+
limit=max(1, min(limit, 200)),
|
| 30 |
+
source_dir=ingest_source,
|
| 31 |
+
force_reingest=force_reingest,
|
| 32 |
+
)
|
| 33 |
+
return {
|
| 34 |
+
"status": "success",
|
| 35 |
+
"sync": sync_result,
|
| 36 |
+
"summary": {
|
| 37 |
+
"source_dir": summary.source_dir,
|
| 38 |
+
"requested_limit": summary.requested_limit,
|
| 39 |
+
"files_seen": summary.files_seen,
|
| 40 |
+
"ingested": summary.ingested,
|
| 41 |
+
"skipped": summary.skipped,
|
| 42 |
+
"failed": summary.failed,
|
| 43 |
+
},
|
| 44 |
+
"failures": summary.failures[:20],
|
| 45 |
+
}
|
backend/mcp_server/tools/vector_search.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
from services.search_service import QdrantSearchService
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def register(mcp: FastMCP) -> None:
|
| 9 |
+
search_service = QdrantSearchService()
|
| 10 |
+
|
| 11 |
+
@mcp.tool()
|
| 12 |
+
def semantic_candidate_search(query: str, top_k: int = 5) -> dict:
|
| 13 |
+
"""Search candidates semantically from Qdrant and return GenUI-ready payloads."""
|
| 14 |
+
rows = search_service.semantic_search(query=query, top_k=top_k)
|
| 15 |
+
if not rows:
|
| 16 |
+
return {
|
| 17 |
+
"status": "no_results",
|
| 18 |
+
"message": "No semantic matches found. Ensure resumes are ingested and Qdrant is configured.",
|
| 19 |
+
"ui": {"summary": "No candidate results", "cards": []},
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
"status": "success",
|
| 24 |
+
"count": len(rows),
|
| 25 |
+
"matches": rows,
|
| 26 |
+
"ui": search_service.build_genui_payload(query=query, rows=rows),
|
| 27 |
+
}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.116.1
|
| 2 |
+
uvicorn[standard]==0.34.2
|
| 3 |
+
python-multipart==0.0.20
|
| 4 |
+
python-dotenv==1.1.0
|
| 5 |
+
pydantic-settings==2.10.1
|
| 6 |
+
sqlalchemy==2.0.41
|
| 7 |
+
pypdf==5.4.0
|
| 8 |
+
qdrant-client==1.14.2
|
| 9 |
+
boto3==1.38.9
|
| 10 |
+
fastmcp==2.10.6
|
| 11 |
+
google-adk==1.5.0
|
| 12 |
+
google-generativeai==0.8.5
|
| 13 |
+
google-genai>=1.21.1
|
| 14 |
+
faker==37.1.0
|
backend/scripts/load_reference_resumes.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
if str(BACKEND_ROOT) not in sys.path:
|
| 9 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 10 |
+
|
| 11 |
+
from core.db import init_db
|
| 12 |
+
from services.reference_data_service import ingest_reference_resumes
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
parser = argparse.ArgumentParser(description="Bulk ingest sample resumes into local database")
|
| 17 |
+
parser.add_argument("--limit", type=int, default=20, help="Maximum number of PDF files to ingest")
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--source-dir",
|
| 20 |
+
type=str,
|
| 21 |
+
default=None,
|
| 22 |
+
help="Optional source directory containing resume PDFs",
|
| 23 |
+
)
|
| 24 |
+
parser.add_argument(
|
| 25 |
+
"--force-reingest",
|
| 26 |
+
action="store_true",
|
| 27 |
+
help="Reprocess files even if matching candidate IDs already exist",
|
| 28 |
+
)
|
| 29 |
+
args = parser.parse_args()
|
| 30 |
+
|
| 31 |
+
init_db()
|
| 32 |
+
summary = ingest_reference_resumes(
|
| 33 |
+
limit=max(1, min(args.limit, 500)),
|
| 34 |
+
source_dir=args.source_dir,
|
| 35 |
+
force_reingest=args.force_reingest,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
print("Reference resume ingestion summary")
|
| 39 |
+
print(f"source_dir: {summary.source_dir}")
|
| 40 |
+
print(f"requested_limit: {summary.requested_limit}")
|
| 41 |
+
print(f"files_seen: {summary.files_seen}")
|
| 42 |
+
print(f"ingested: {summary.ingested}")
|
| 43 |
+
print(f"skipped: {summary.skipped}")
|
| 44 |
+
print(f"failed: {summary.failed}")
|
| 45 |
+
|
| 46 |
+
if summary.failures:
|
| 47 |
+
print("failures:")
|
| 48 |
+
for item in summary.failures:
|
| 49 |
+
print(f" - {item['file']}: {item['error']}")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
main()
|
backend/scripts/sync_reference_resumes.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
if str(BACKEND_ROOT) not in sys.path:
|
| 9 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 10 |
+
|
| 11 |
+
from services.reference_data_service import sync_reference_resumes_to_local
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> None:
|
| 15 |
+
parser = argparse.ArgumentParser(description="Copy reference resumes into local project storage")
|
| 16 |
+
parser.add_argument(
|
| 17 |
+
"--max-files",
|
| 18 |
+
type=int,
|
| 19 |
+
default=120,
|
| 20 |
+
help="Maximum number of PDF resumes to copy",
|
| 21 |
+
)
|
| 22 |
+
parser.add_argument(
|
| 23 |
+
"--source-dir",
|
| 24 |
+
type=str,
|
| 25 |
+
default=None,
|
| 26 |
+
help="Optional source directory containing PDF resumes",
|
| 27 |
+
)
|
| 28 |
+
args = parser.parse_args()
|
| 29 |
+
|
| 30 |
+
summary = sync_reference_resumes_to_local(
|
| 31 |
+
max_files=max(1, min(args.max_files, 1000)),
|
| 32 |
+
source_dir=args.source_dir,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
print("Reference resume sync summary")
|
| 36 |
+
print(f"source_dir: {summary['source_dir']}")
|
| 37 |
+
print(f"target_dir: {summary['target_dir']}")
|
| 38 |
+
print(f"files_seen: {summary['files_seen']}")
|
| 39 |
+
print(f"copied: {summary['copied']}")
|
| 40 |
+
print(f"skipped: {summary['skipped']}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
main()
|
backend/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Service layer for ingestion, search, embeddings, and storage."""
|
backend/services/candidate_service.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import or_, select
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from core.models import Candidate, QueryAudit
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CandidateService:
|
| 14 |
+
def upsert_candidate(
|
| 15 |
+
self,
|
| 16 |
+
db: Session,
|
| 17 |
+
*,
|
| 18 |
+
external_id: str | None,
|
| 19 |
+
full_name: str | None,
|
| 20 |
+
email: str | None,
|
| 21 |
+
phone: str | None,
|
| 22 |
+
location: str | None,
|
| 23 |
+
years_experience: float | None,
|
| 24 |
+
skills: list[str],
|
| 25 |
+
summary: str,
|
| 26 |
+
source_resume_key: str | None,
|
| 27 |
+
source_resume_url: str | None,
|
| 28 |
+
) -> Candidate:
|
| 29 |
+
candidate_id = external_id or str(uuid.uuid4())
|
| 30 |
+
|
| 31 |
+
existing = db.execute(
|
| 32 |
+
select(Candidate).where(Candidate.external_id == candidate_id)
|
| 33 |
+
).scalar_one_or_none()
|
| 34 |
+
|
| 35 |
+
if existing is None:
|
| 36 |
+
existing = Candidate(external_id=candidate_id)
|
| 37 |
+
db.add(existing)
|
| 38 |
+
|
| 39 |
+
existing.full_name = full_name
|
| 40 |
+
existing.email = email
|
| 41 |
+
existing.phone = phone
|
| 42 |
+
existing.location = location
|
| 43 |
+
existing.years_experience = years_experience
|
| 44 |
+
existing.skills_csv = ", ".join([skill.strip() for skill in skills if skill.strip()])
|
| 45 |
+
existing.summary = summary
|
| 46 |
+
existing.source_resume_key = source_resume_key
|
| 47 |
+
existing.source_resume_url = source_resume_url
|
| 48 |
+
|
| 49 |
+
db.commit()
|
| 50 |
+
db.refresh(existing)
|
| 51 |
+
return existing
|
| 52 |
+
|
| 53 |
+
# Role-keyword aliases used to harden role-based filtering. When a recruiter
|
| 54 |
+
# searches for "AI engineers" we want only candidates whose skills/summary
|
| 55 |
+
# actually mention AI/ML/NLP — not the whole pool.
|
| 56 |
+
_ROLE_KEYWORDS: dict[str, tuple[str, ...]] = {
|
| 57 |
+
"ai": ("ai", "ml", "machine learning", "deep learning", "nlp", "llm", "tensorflow", "pytorch"),
|
| 58 |
+
"ml": ("ml", "machine learning", "deep learning", "nlp", "tensorflow", "pytorch", "scikit"),
|
| 59 |
+
"data": ("data", "analytics", "sql", "pandas", "spark", "hadoop", "warehouse"),
|
| 60 |
+
"backend": ("backend", "fastapi", "django", "node", "spring", "go", "microservice", "api"),
|
| 61 |
+
"frontend": ("frontend", "react", "next.js", "vue", "angular", "typescript", "javascript", "html", "css"),
|
| 62 |
+
"fullstack": ("full stack", "fullstack", "react", "node", "django", "fastapi", "typescript"),
|
| 63 |
+
"devops": ("devops", "kubernetes", "docker", "aws", "gcp", "azure", "terraform", "ci/cd"),
|
| 64 |
+
"security": ("security", "cyber", "siem", "soc", "penetration", "infosec", "iam"),
|
| 65 |
+
"cloud": ("cloud", "aws", "gcp", "azure", "kubernetes", "terraform"),
|
| 66 |
+
"mobile": ("mobile", "android", "ios", "kotlin", "swift", "react native", "flutter"),
|
| 67 |
+
"qa": ("qa", "test", "cypress", "playwright", "selenium", "automation"),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
def _expand_role_tokens(self, tokens: list[str]) -> tuple[list[str], list[str]]:
|
| 71 |
+
"""Return (required_keywords, role_anchors).
|
| 72 |
+
|
| 73 |
+
Required keywords are the at-least-one set the candidate must match.
|
| 74 |
+
Role anchors get stronger weighting in the scoring loop below.
|
| 75 |
+
"""
|
| 76 |
+
required: set[str] = set()
|
| 77 |
+
anchors: list[str] = []
|
| 78 |
+
for tok in tokens:
|
| 79 |
+
for role, expansions in self._ROLE_KEYWORDS.items():
|
| 80 |
+
if tok == role or tok in expansions:
|
| 81 |
+
anchors.append(role)
|
| 82 |
+
required.update(expansions)
|
| 83 |
+
return sorted(required), anchors
|
| 84 |
+
|
| 85 |
+
def search_candidates(self, db: Session, query: str, limit: int = 10) -> list[Candidate]:
|
| 86 |
+
raw_query = query.strip()
|
| 87 |
+
if not raw_query:
|
| 88 |
+
return self.list_recent_candidates(db, limit=limit)
|
| 89 |
+
|
| 90 |
+
tokens = self._extract_tokens(raw_query)
|
| 91 |
+
required_keywords, role_anchors = self._expand_role_tokens(tokens)
|
| 92 |
+
|
| 93 |
+
# When the user asks role-specific ("AI engineers"), require the candidate to
|
| 94 |
+
# mention at least one related keyword. Falls back to the original token set
|
| 95 |
+
# when no role anchor is detected.
|
| 96 |
+
match_keywords = required_keywords if required_keywords else tokens
|
| 97 |
+
patterns = [f"%{token}%" for token in match_keywords] if match_keywords else [f"%{raw_query}%"]
|
| 98 |
+
|
| 99 |
+
conditions = []
|
| 100 |
+
for pattern in patterns:
|
| 101 |
+
conditions.extend(
|
| 102 |
+
[
|
| 103 |
+
Candidate.full_name.ilike(pattern),
|
| 104 |
+
Candidate.skills_csv.ilike(pattern),
|
| 105 |
+
Candidate.location.ilike(pattern),
|
| 106 |
+
Candidate.summary.ilike(pattern),
|
| 107 |
+
]
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
stmt = (
|
| 111 |
+
select(Candidate)
|
| 112 |
+
.where(or_(*conditions))
|
| 113 |
+
.order_by(Candidate.updated_at.desc())
|
| 114 |
+
.limit(max(limit * 6, 40))
|
| 115 |
+
)
|
| 116 |
+
rows = list(db.execute(stmt).scalars().all())
|
| 117 |
+
|
| 118 |
+
if not rows:
|
| 119 |
+
return []
|
| 120 |
+
|
| 121 |
+
scored_rows: list[tuple[int, Candidate]] = []
|
| 122 |
+
for row in rows:
|
| 123 |
+
haystack = " ".join(
|
| 124 |
+
[
|
| 125 |
+
row.full_name or "",
|
| 126 |
+
row.skills_csv or "",
|
| 127 |
+
row.location or "",
|
| 128 |
+
row.summary or "",
|
| 129 |
+
]
|
| 130 |
+
).lower()
|
| 131 |
+
|
| 132 |
+
score = 0
|
| 133 |
+
for token in tokens:
|
| 134 |
+
if token in haystack:
|
| 135 |
+
score += 3
|
| 136 |
+
|
| 137 |
+
# Strong boost when the candidate hits any keyword from the user's role.
|
| 138 |
+
if role_anchors:
|
| 139 |
+
role_hits = sum(1 for kw in required_keywords if kw in haystack)
|
| 140 |
+
score += role_hits * 6
|
| 141 |
+
# Hard gate: drop candidates that don't match the role at all.
|
| 142 |
+
if role_hits == 0:
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
if row.location and any(city in row.location.lower() for city in ("bangalore", "bengaluru")):
|
| 146 |
+
if any(city in tokens for city in ("bangalore", "bengaluru")):
|
| 147 |
+
score += 4
|
| 148 |
+
|
| 149 |
+
scored_rows.append((score, row))
|
| 150 |
+
|
| 151 |
+
ranked = [item for item in sorted(scored_rows, key=lambda pair: pair[0], reverse=True) if item[0] > 0]
|
| 152 |
+
if not ranked:
|
| 153 |
+
return [] # honour the role gate even when no candidate scored
|
| 154 |
+
|
| 155 |
+
ranked_rows = [row for _, row in ranked]
|
| 156 |
+
return self._dedupe_candidates(ranked_rows, limit)
|
| 157 |
+
|
| 158 |
+
def list_recent_candidates(self, db: Session, limit: int = 10) -> list[Candidate]:
|
| 159 |
+
stmt = select(Candidate).order_by(Candidate.updated_at.desc()).limit(limit)
|
| 160 |
+
rows = list(db.execute(stmt).scalars().all())
|
| 161 |
+
return self._dedupe_candidates(rows, limit)
|
| 162 |
+
|
| 163 |
+
def get_candidate(self, db: Session, candidate_id: str) -> Candidate | None:
|
| 164 |
+
clean_id = (candidate_id or "").strip()
|
| 165 |
+
if not clean_id:
|
| 166 |
+
return None
|
| 167 |
+
return db.execute(
|
| 168 |
+
select(Candidate).where(Candidate.external_id == clean_id)
|
| 169 |
+
).scalar_one_or_none()
|
| 170 |
+
|
| 171 |
+
def build_profile_payload(self, candidate: Candidate) -> dict[str, Any]:
|
| 172 |
+
skills = [token.strip() for token in (candidate.skills_csv or "").split(",") if token.strip()]
|
| 173 |
+
meta = {
|
| 174 |
+
"candidate_id": candidate.external_id,
|
| 175 |
+
"email": candidate.email,
|
| 176 |
+
"phone": candidate.phone,
|
| 177 |
+
"location": candidate.location,
|
| 178 |
+
"experience_years": candidate.years_experience,
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
display_name = candidate.full_name or candidate.external_id
|
| 182 |
+
meta_lines = [f"- **Name:** {display_name}"]
|
| 183 |
+
if candidate.email:
|
| 184 |
+
meta_lines.append(f"- **Email:** {candidate.email}")
|
| 185 |
+
if candidate.phone:
|
| 186 |
+
meta_lines.append(f"- **Phone:** {candidate.phone}")
|
| 187 |
+
if candidate.location:
|
| 188 |
+
meta_lines.append(f"- **Location:** {candidate.location}")
|
| 189 |
+
if candidate.years_experience is not None:
|
| 190 |
+
meta_lines.append(f"- **Experience:** {candidate.years_experience} years")
|
| 191 |
+
if skills:
|
| 192 |
+
meta_lines.append(f"- **Skills:** {', '.join(skills)}")
|
| 193 |
+
if candidate.source_resume_url:
|
| 194 |
+
meta_lines.append(f"- **Resume:** [Open PDF]({candidate.source_resume_url})")
|
| 195 |
+
|
| 196 |
+
summary_md = "\n".join(meta_lines)
|
| 197 |
+
if candidate.summary:
|
| 198 |
+
summary_md += "\n\n**Resume Summary**\n\n" + candidate.summary[:1200]
|
| 199 |
+
|
| 200 |
+
card = {
|
| 201 |
+
"title": display_name,
|
| 202 |
+
"subtitle": (candidate.summary or "")[:160] or "Candidate profile",
|
| 203 |
+
"tags": skills[:6],
|
| 204 |
+
"meta": meta,
|
| 205 |
+
"actions": [],
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
years = float(candidate.years_experience or 0)
|
| 209 |
+
skill_groups = self._classify_skills(skills)
|
| 210 |
+
chart = {
|
| 211 |
+
"type": "radar",
|
| 212 |
+
"title": "Skill profile",
|
| 213 |
+
"xKey": "name",
|
| 214 |
+
"yKey": "score",
|
| 215 |
+
"data": [
|
| 216 |
+
{"name": label, "score": value}
|
| 217 |
+
for label, value in [
|
| 218 |
+
("Backend", skill_groups.get("backend", 0)),
|
| 219 |
+
("Frontend", skill_groups.get("frontend", 0)),
|
| 220 |
+
("Cloud", skill_groups.get("cloud", 0)),
|
| 221 |
+
("Data/ML", skill_groups.get("data", 0)),
|
| 222 |
+
("Years", round(min(years, 10), 1)),
|
| 223 |
+
]
|
| 224 |
+
],
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
skills_chip_line = (
|
| 228 |
+
" ".join(f"`{skill}`" for skill in skills) if skills else "_No skills detected._"
|
| 229 |
+
)
|
| 230 |
+
contact_line = " · ".join(
|
| 231 |
+
line
|
| 232 |
+
for line in (
|
| 233 |
+
f"📧 {candidate.email}" if candidate.email else "",
|
| 234 |
+
f"📞 {candidate.phone}" if candidate.phone else "",
|
| 235 |
+
f"📍 {candidate.location}" if candidate.location else "",
|
| 236 |
+
f"🧭 {candidate.years_experience} yrs" if candidate.years_experience is not None else "",
|
| 237 |
+
)
|
| 238 |
+
if line
|
| 239 |
+
) or "_Contact info unavailable._"
|
| 240 |
+
resume_md = (candidate.summary or "_Resume summary not available._")[:1500]
|
| 241 |
+
resume_link = (
|
| 242 |
+
f"\n\n[Open original PDF]({candidate.source_resume_url})"
|
| 243 |
+
if candidate.source_resume_url
|
| 244 |
+
else ""
|
| 245 |
+
)
|
| 246 |
+
markdown_body = (
|
| 247 |
+
f"# {display_name}\n\n"
|
| 248 |
+
f"{contact_line}\n\n"
|
| 249 |
+
f"### Skills\n{skills_chip_line}\n\n"
|
| 250 |
+
f"### Resume snapshot\n{resume_md}{resume_link}"
|
| 251 |
+
)
|
| 252 |
+
safe_md = (
|
| 253 |
+
markdown_body.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 254 |
+
)
|
| 255 |
+
c1_response = f"<content><custom_markdown>{safe_md}</custom_markdown></content>"
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
"summary": summary_md,
|
| 259 |
+
"cards": [card],
|
| 260 |
+
"chart": chart,
|
| 261 |
+
"c1_response": c1_response,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
def get_by_external_ids(self, db: Session, external_ids: list[str]) -> list[Candidate]:
|
| 265 |
+
if not external_ids:
|
| 266 |
+
return []
|
| 267 |
+
stmt = select(Candidate).where(Candidate.external_id.in_(external_ids))
|
| 268 |
+
return list(db.execute(stmt).scalars().all())
|
| 269 |
+
|
| 270 |
+
def audit_query(self, db: Session, session_id: str, query: str, top_k: int) -> None:
|
| 271 |
+
db.add(QueryAudit(session_id=session_id, query=query, top_k=top_k))
|
| 272 |
+
db.commit()
|
| 273 |
+
|
| 274 |
+
def build_genui_payload(self, query: str, candidates: list[Candidate]) -> dict[str, Any]:
|
| 275 |
+
cards = [self.to_card(candidate) for candidate in candidates]
|
| 276 |
+
|
| 277 |
+
# Chart 1: experience distribution per candidate (bar/auto).
|
| 278 |
+
chart_data = [
|
| 279 |
+
{
|
| 280 |
+
"name": candidate.full_name or candidate.external_id[:8],
|
| 281 |
+
"years": float(candidate.years_experience or 0),
|
| 282 |
+
}
|
| 283 |
+
for candidate in candidates
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
# Chart 2: skill cluster across the matched pool — adapts to whoever shows up.
|
| 287 |
+
cluster_totals: dict[str, int] = {}
|
| 288 |
+
for c in candidates:
|
| 289 |
+
skills = [t.strip() for t in (c.skills_csv or "").split(",") if t.strip()]
|
| 290 |
+
buckets = self._classify_skills(skills)
|
| 291 |
+
for bucket, count in buckets.items():
|
| 292 |
+
cluster_totals[bucket] = cluster_totals.get(bucket, 0) + count
|
| 293 |
+
skill_chart = {
|
| 294 |
+
"type": "auto", # let the frontend pick donut/radar based on shape
|
| 295 |
+
"title": f"Skill mix across {len(candidates)} match(es)",
|
| 296 |
+
"xKey": "name",
|
| 297 |
+
"yKey": "value",
|
| 298 |
+
"data": [
|
| 299 |
+
{"name": label.title(), "value": cluster_totals.get(label, 0)}
|
| 300 |
+
for label in ("backend", "frontend", "cloud", "data")
|
| 301 |
+
if cluster_totals.get(label, 0) > 0
|
| 302 |
+
],
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
# Prefer the skill-mix chart when we actually have skill data; fall back to
|
| 306 |
+
# the experience overview otherwise. This is dynamic per query.
|
| 307 |
+
chart = (
|
| 308 |
+
skill_chart
|
| 309 |
+
if skill_chart["data"]
|
| 310 |
+
else {
|
| 311 |
+
"type": "bar",
|
| 312 |
+
"title": "Experience overview",
|
| 313 |
+
"xKey": "name",
|
| 314 |
+
"yKey": "years",
|
| 315 |
+
"data": chart_data,
|
| 316 |
+
}
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
return {
|
| 320 |
+
"summary": f"Found {len(candidates)} profile(s) matching '{query}' from local metadata.",
|
| 321 |
+
"cards": cards,
|
| 322 |
+
"chart": chart,
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
@staticmethod
|
| 326 |
+
def to_card(candidate: Candidate, score: float | None = None) -> dict[str, Any]:
|
| 327 |
+
tags = []
|
| 328 |
+
if candidate.skills_csv:
|
| 329 |
+
tags = [token.strip() for token in candidate.skills_csv.split(",") if token.strip()][:4]
|
| 330 |
+
|
| 331 |
+
return {
|
| 332 |
+
"candidate_id": candidate.external_id,
|
| 333 |
+
"title": candidate.full_name or candidate.external_id,
|
| 334 |
+
"subtitle": candidate.summary or "Candidate profile",
|
| 335 |
+
"tags": tags,
|
| 336 |
+
"meta": {
|
| 337 |
+
"experience": candidate.years_experience,
|
| 338 |
+
"location": candidate.location,
|
| 339 |
+
"score": round(score, 4) if score is not None else None,
|
| 340 |
+
},
|
| 341 |
+
"actions": [
|
| 342 |
+
{"label": "Open Profile", "action": f"open_profile:{candidate.external_id}"},
|
| 343 |
+
],
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
@staticmethod
|
| 347 |
+
def _classify_skills(skills: list[str]) -> dict[str, int]:
|
| 348 |
+
"""Bucket skills into broad categories for radar-chart axes."""
|
| 349 |
+
backend = {"python", "java", "go", "node", "fastapi", "django", "spring", "rails", "ruby", "c#", ".net", "scala"}
|
| 350 |
+
frontend = {"react", "next.js", "vue", "angular", "typescript", "javascript", "html", "css", "redux"}
|
| 351 |
+
cloud = {"aws", "gcp", "azure", "docker", "kubernetes", "terraform", "lambda"}
|
| 352 |
+
data = {"sql", "ml", "nlp", "llm", "tensorflow", "pytorch", "spark", "hadoop", "pandas", "numpy", "ai"}
|
| 353 |
+
buckets = {"backend": 0, "frontend": 0, "cloud": 0, "data": 0}
|
| 354 |
+
for raw in skills:
|
| 355 |
+
tok = raw.lower().strip()
|
| 356 |
+
if tok in backend:
|
| 357 |
+
buckets["backend"] += 1
|
| 358 |
+
if tok in frontend:
|
| 359 |
+
buckets["frontend"] += 1
|
| 360 |
+
if tok in cloud:
|
| 361 |
+
buckets["cloud"] += 1
|
| 362 |
+
if tok in data:
|
| 363 |
+
buckets["data"] += 1
|
| 364 |
+
return buckets
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def _extract_tokens(query: str) -> list[str]:
|
| 368 |
+
stopwords = {
|
| 369 |
+
"find",
|
| 370 |
+
"top",
|
| 371 |
+
"candidate",
|
| 372 |
+
"candidates",
|
| 373 |
+
"for",
|
| 374 |
+
"with",
|
| 375 |
+
"and",
|
| 376 |
+
"the",
|
| 377 |
+
"a",
|
| 378 |
+
"an",
|
| 379 |
+
"in",
|
| 380 |
+
"of",
|
| 381 |
+
"to",
|
| 382 |
+
"from",
|
| 383 |
+
"show",
|
| 384 |
+
"list",
|
| 385 |
+
"please",
|
| 386 |
+
"need",
|
| 387 |
+
"looking",
|
| 388 |
+
"senior",
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
raw_tokens = re.findall(r"[a-zA-Z0-9+#.]{2,}", query.lower())
|
| 392 |
+
deduped: list[str] = []
|
| 393 |
+
for token in raw_tokens:
|
| 394 |
+
if token in stopwords:
|
| 395 |
+
continue
|
| 396 |
+
if token not in deduped:
|
| 397 |
+
deduped.append(token)
|
| 398 |
+
return deduped
|
| 399 |
+
|
| 400 |
+
@staticmethod
|
| 401 |
+
def _dedupe_candidates(rows: list[Candidate], limit: int) -> list[Candidate]:
|
| 402 |
+
output: list[Candidate] = []
|
| 403 |
+
seen: set[str] = set()
|
| 404 |
+
|
| 405 |
+
for row in rows:
|
| 406 |
+
key = (row.full_name or "").strip().lower() or row.external_id
|
| 407 |
+
if key in seen:
|
| 408 |
+
continue
|
| 409 |
+
seen.add(key)
|
| 410 |
+
output.append(row)
|
| 411 |
+
if len(output) >= limit:
|
| 412 |
+
break
|
| 413 |
+
|
| 414 |
+
return output
|
backend/services/embedding_service.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
|
| 7 |
+
from core.config import get_settings
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EmbeddingService:
|
| 13 |
+
def __init__(self) -> None:
|
| 14 |
+
self.settings = get_settings()
|
| 15 |
+
self._is_configured = False
|
| 16 |
+
|
| 17 |
+
if self.settings.gemini_api_key:
|
| 18 |
+
genai.configure(api_key=self.settings.gemini_api_key)
|
| 19 |
+
self._is_configured = True
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def is_ready(self) -> bool:
|
| 23 |
+
return self._is_configured
|
| 24 |
+
|
| 25 |
+
def embed_document(self, text: str) -> list[float]:
|
| 26 |
+
return self._embed(text=text, task_type="retrieval_document")
|
| 27 |
+
|
| 28 |
+
def embed_query(self, text: str) -> list[float]:
|
| 29 |
+
return self._embed(text=text, task_type="retrieval_query")
|
| 30 |
+
|
| 31 |
+
def _embed(self, text: str, task_type: str) -> list[float]:
|
| 32 |
+
if not self._is_configured:
|
| 33 |
+
raise RuntimeError("GEMINI_API_KEY is required to generate embeddings.")
|
| 34 |
+
|
| 35 |
+
response = genai.embed_content(
|
| 36 |
+
model=self.settings.embedding_model,
|
| 37 |
+
content=text,
|
| 38 |
+
task_type=task_type,
|
| 39 |
+
)
|
| 40 |
+
embedding = response.get("embedding")
|
| 41 |
+
if not embedding:
|
| 42 |
+
raise RuntimeError("Embedding response did not include an embedding vector.")
|
| 43 |
+
return embedding
|
backend/services/ingestion_service.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import uuid
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
from pypdf import PdfReader
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from core.config import get_settings
|
| 13 |
+
from services.candidate_service import CandidateService
|
| 14 |
+
from services.search_service import QdrantSearchService
|
| 15 |
+
from services.storage_service import ObjectStorageService
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
| 20 |
+
PHONE_PATTERN = re.compile(r"(?:\+\d{1,3}[\s-]?)?(?:\(?\d{3}\)?[\s-]?)?\d{3}[\s-]?\d{4}")
|
| 21 |
+
EXPERIENCE_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(?:\+)?\s*years?", flags=re.IGNORECASE)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class IngestionResult:
|
| 26 |
+
candidate_id: str
|
| 27 |
+
file_name: str
|
| 28 |
+
chunks_indexed: int
|
| 29 |
+
r2_url: str | None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class IngestionService:
|
| 33 |
+
def __init__(self) -> None:
|
| 34 |
+
self.settings = get_settings()
|
| 35 |
+
self.candidate_service = CandidateService()
|
| 36 |
+
self.search_service = QdrantSearchService()
|
| 37 |
+
self.storage_service = ObjectStorageService()
|
| 38 |
+
|
| 39 |
+
def ingest_pdf(
|
| 40 |
+
self,
|
| 41 |
+
db: Session,
|
| 42 |
+
*,
|
| 43 |
+
file_name: str,
|
| 44 |
+
file_bytes: bytes,
|
| 45 |
+
external_id: str | None = None,
|
| 46 |
+
) -> IngestionResult:
|
| 47 |
+
try:
|
| 48 |
+
text = self._extract_pdf_text(file_bytes)
|
| 49 |
+
except Exception:
|
| 50 |
+
text = ""
|
| 51 |
+
|
| 52 |
+
if not text.strip():
|
| 53 |
+
# pypdf failed (scanned / image-only PDF). Fall back to Gemini's native PDF
|
| 54 |
+
# support which OCRs the document.
|
| 55 |
+
try:
|
| 56 |
+
text = self._extract_with_gemini(file_bytes, file_name)
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
logger.warning("Gemini PDF OCR failed for %s: %s", file_name, exc)
|
| 59 |
+
text = ""
|
| 60 |
+
|
| 61 |
+
if not text.strip():
|
| 62 |
+
# Last-resort: generate a minimal record from the filename so the candidate
|
| 63 |
+
# still appears in search.
|
| 64 |
+
text = self._fallback_text_from_name(file_name)
|
| 65 |
+
|
| 66 |
+
profile = self._extract_profile(text)
|
| 67 |
+
if not profile.get("full_name"):
|
| 68 |
+
profile["full_name"] = self._candidate_name_from_filename(file_name)
|
| 69 |
+
|
| 70 |
+
summary = text[:1500]
|
| 71 |
+
|
| 72 |
+
object_key, r2_url = self.storage_service.upload_resume(file_name=file_name, file_bytes=file_bytes)
|
| 73 |
+
|
| 74 |
+
candidate_id = external_id or str(uuid.uuid4())
|
| 75 |
+
candidate = self.candidate_service.upsert_candidate(
|
| 76 |
+
db,
|
| 77 |
+
external_id=candidate_id,
|
| 78 |
+
full_name=profile["full_name"],
|
| 79 |
+
email=profile["email"],
|
| 80 |
+
phone=profile["phone"],
|
| 81 |
+
location=profile["location"],
|
| 82 |
+
years_experience=profile["years_experience"],
|
| 83 |
+
skills=profile["skills"],
|
| 84 |
+
summary=summary,
|
| 85 |
+
source_resume_key=object_key,
|
| 86 |
+
source_resume_url=r2_url,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
chunks = self._chunk_text(text)
|
| 90 |
+
metadata = {
|
| 91 |
+
"full_name": candidate.full_name,
|
| 92 |
+
"email": candidate.email,
|
| 93 |
+
"location": candidate.location,
|
| 94 |
+
"skills": candidate.skills_csv,
|
| 95 |
+
"years_experience": candidate.years_experience,
|
| 96 |
+
"source_resume_url": candidate.source_resume_url,
|
| 97 |
+
"file_name": file_name,
|
| 98 |
+
}
|
| 99 |
+
indexed_count = self.search_service.upsert_document_chunks(
|
| 100 |
+
candidate_id=candidate.external_id,
|
| 101 |
+
chunks=chunks,
|
| 102 |
+
metadata=metadata,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return IngestionResult(
|
| 106 |
+
candidate_id=candidate.external_id,
|
| 107 |
+
file_name=file_name,
|
| 108 |
+
chunks_indexed=indexed_count,
|
| 109 |
+
r2_url=r2_url,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def ingest_text_profile(
|
| 113 |
+
self,
|
| 114 |
+
db: Session,
|
| 115 |
+
*,
|
| 116 |
+
candidate_name: str,
|
| 117 |
+
resume_text: str,
|
| 118 |
+
location: str | None = None,
|
| 119 |
+
skills: list[str] | None = None,
|
| 120 |
+
years_experience: float | None = None,
|
| 121 |
+
) -> IngestionResult:
|
| 122 |
+
if not resume_text.strip():
|
| 123 |
+
raise ValueError("resume_text cannot be empty.")
|
| 124 |
+
|
| 125 |
+
candidate_id = str(uuid.uuid4())
|
| 126 |
+
candidate = self.candidate_service.upsert_candidate(
|
| 127 |
+
db,
|
| 128 |
+
external_id=candidate_id,
|
| 129 |
+
full_name=candidate_name,
|
| 130 |
+
email=None,
|
| 131 |
+
phone=None,
|
| 132 |
+
location=location,
|
| 133 |
+
years_experience=years_experience,
|
| 134 |
+
skills=skills or [],
|
| 135 |
+
summary=resume_text[:800],
|
| 136 |
+
source_resume_key=None,
|
| 137 |
+
source_resume_url=None,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
chunks = self._chunk_text(resume_text)
|
| 141 |
+
metadata = {
|
| 142 |
+
"full_name": candidate.full_name,
|
| 143 |
+
"location": candidate.location,
|
| 144 |
+
"skills": candidate.skills_csv,
|
| 145 |
+
"years_experience": candidate.years_experience,
|
| 146 |
+
"source_resume_url": candidate.source_resume_url,
|
| 147 |
+
"file_name": f"{candidate_name}.txt",
|
| 148 |
+
}
|
| 149 |
+
indexed_count = self.search_service.upsert_document_chunks(
|
| 150 |
+
candidate_id=candidate.external_id,
|
| 151 |
+
chunks=chunks,
|
| 152 |
+
metadata=metadata,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return IngestionResult(
|
| 156 |
+
candidate_id=candidate.external_id,
|
| 157 |
+
file_name=f"{candidate_name}.txt",
|
| 158 |
+
chunks_indexed=indexed_count,
|
| 159 |
+
r2_url=None,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def _extract_pdf_text(self, file_bytes: bytes) -> str:
|
| 163 |
+
reader = PdfReader(io.BytesIO(file_bytes))
|
| 164 |
+
pages = [page.extract_text() or "" for page in reader.pages]
|
| 165 |
+
return "\n".join(pages)
|
| 166 |
+
|
| 167 |
+
def _extract_with_gemini(self, file_bytes: bytes, file_name: str) -> str:
|
| 168 |
+
"""Use Gemini's native PDF support to OCR scanned/image-only resumes.
|
| 169 |
+
|
| 170 |
+
Gemini 2.5 Pro accepts raw PDF bytes via inline parts and can read
|
| 171 |
+
text from scanned documents. This is our fallback when pypdf yields
|
| 172 |
+
nothing.
|
| 173 |
+
"""
|
| 174 |
+
api_key = self.settings.gemini_api_key
|
| 175 |
+
if not api_key:
|
| 176 |
+
raise RuntimeError("GEMINI_API_KEY is not configured.")
|
| 177 |
+
|
| 178 |
+
from google import genai
|
| 179 |
+
from google.genai import types
|
| 180 |
+
|
| 181 |
+
client = genai.Client(api_key=api_key)
|
| 182 |
+
prompt = (
|
| 183 |
+
"You are reading a candidate resume PDF. Extract every piece of text "
|
| 184 |
+
"and structure it cleanly: name, contact (email/phone/location), "
|
| 185 |
+
"experience entries, education, skills, certifications, and a short "
|
| 186 |
+
"professional summary. Preserve dates and company names exactly. Return "
|
| 187 |
+
"plain text — no JSON, no markdown."
|
| 188 |
+
)
|
| 189 |
+
response = client.models.generate_content(
|
| 190 |
+
model=self.settings.gemini_model or "gemini-2.5-pro",
|
| 191 |
+
contents=[
|
| 192 |
+
types.Part.from_bytes(data=file_bytes, mime_type="application/pdf"),
|
| 193 |
+
prompt,
|
| 194 |
+
],
|
| 195 |
+
)
|
| 196 |
+
text = getattr(response, "text", None) or ""
|
| 197 |
+
return text.strip()
|
| 198 |
+
|
| 199 |
+
@staticmethod
|
| 200 |
+
def _candidate_name_from_filename(file_name: str) -> str:
|
| 201 |
+
base = file_name.rsplit(".", 1)[0]
|
| 202 |
+
cleaned = re.sub(r"[_\-]+", " ", base).strip()
|
| 203 |
+
if not cleaned:
|
| 204 |
+
return file_name
|
| 205 |
+
return cleaned.title()
|
| 206 |
+
|
| 207 |
+
@classmethod
|
| 208 |
+
def _fallback_text_from_name(cls, file_name: str) -> str:
|
| 209 |
+
name = cls._candidate_name_from_filename(file_name)
|
| 210 |
+
return (
|
| 211 |
+
f"Candidate {name}. Resume PDF uploaded as {file_name}. "
|
| 212 |
+
"PDF text extraction was not possible (likely scanned or image-only). "
|
| 213 |
+
"Open the original file via the resume URL for full content."
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def _extract_profile(self, text: str) -> dict[str, object]:
|
| 217 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 218 |
+
first_line = lines[0] if lines else None
|
| 219 |
+
|
| 220 |
+
email_match = EMAIL_PATTERN.search(text)
|
| 221 |
+
phone_match = PHONE_PATTERN.search(text)
|
| 222 |
+
exp_match = EXPERIENCE_PATTERN.search(text)
|
| 223 |
+
|
| 224 |
+
lower_text = text.lower()
|
| 225 |
+
skill_candidates = [
|
| 226 |
+
"python",
|
| 227 |
+
"java",
|
| 228 |
+
"typescript",
|
| 229 |
+
"react",
|
| 230 |
+
"next.js",
|
| 231 |
+
"fastapi",
|
| 232 |
+
"sql",
|
| 233 |
+
"aws",
|
| 234 |
+
"gcp",
|
| 235 |
+
"docker",
|
| 236 |
+
"kubernetes",
|
| 237 |
+
"ml",
|
| 238 |
+
"nlp",
|
| 239 |
+
"llm",
|
| 240 |
+
]
|
| 241 |
+
found_skills = [skill for skill in skill_candidates if skill in lower_text]
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"full_name": first_line,
|
| 245 |
+
"email": email_match.group(0) if email_match else None,
|
| 246 |
+
"phone": phone_match.group(0) if phone_match else None,
|
| 247 |
+
"location": None,
|
| 248 |
+
"years_experience": float(exp_match.group(1)) if exp_match else None,
|
| 249 |
+
"skills": found_skills,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
def _chunk_text(self, text: str) -> list[str]:
|
| 253 |
+
cleaned = " ".join(text.split())
|
| 254 |
+
chunk_size = self.settings.chunk_size
|
| 255 |
+
overlap = self.settings.chunk_overlap
|
| 256 |
+
max_chunks = self.settings.max_chunks_per_document
|
| 257 |
+
|
| 258 |
+
if len(cleaned) <= chunk_size:
|
| 259 |
+
return [cleaned]
|
| 260 |
+
|
| 261 |
+
chunks: list[str] = []
|
| 262 |
+
start = 0
|
| 263 |
+
while start < len(cleaned) and len(chunks) < max_chunks:
|
| 264 |
+
end = min(start + chunk_size, len(cleaned))
|
| 265 |
+
chunks.append(cleaned[start:end])
|
| 266 |
+
if end == len(cleaned):
|
| 267 |
+
break
|
| 268 |
+
start = max(0, end - overlap)
|
| 269 |
+
|
| 270 |
+
return chunks
|
backend/services/notification_service.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Email + meeting-link helpers for interview scheduling.
|
| 2 |
+
|
| 3 |
+
When SMTP credentials are configured (SMTP_HOST, SMTP_USER, SMTP_PASSWORD,
|
| 4 |
+
SMTP_FROM in .env), sending an interview invite goes out automatically with the
|
| 5 |
+
.ics calendar file attached and a Google Meet room URL embedded in the body.
|
| 6 |
+
|
| 7 |
+
If SMTP is not configured the helper returns ``sent=False`` along with the same
|
| 8 |
+
artefacts so the agent can degrade gracefully and tell the user how to configure
|
| 9 |
+
auto-send.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import smtplib
|
| 15 |
+
import uuid
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from email.message import EmailMessage
|
| 19 |
+
|
| 20 |
+
from core.config import get_settings
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class InviteSendResult:
|
| 27 |
+
sent: bool
|
| 28 |
+
delivery: str # "smtp" | "skipped" | "failed"
|
| 29 |
+
detail: str
|
| 30 |
+
meet_url: str
|
| 31 |
+
recipients: list[str]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def generate_meet_url() -> str:
|
| 35 |
+
"""Generate a Google Meet URL using their canonical room-id pattern.
|
| 36 |
+
|
| 37 |
+
Google Meet room ids look like ``xxx-yyyy-zzz`` (3-4-3 lowercase letters). When
|
| 38 |
+
an authenticated Google user opens such a URL, Google Meet creates the room on
|
| 39 |
+
first access and both invited attendees land in the same meeting.
|
| 40 |
+
"""
|
| 41 |
+
import secrets
|
| 42 |
+
import string
|
| 43 |
+
|
| 44 |
+
alphabet = string.ascii_lowercase
|
| 45 |
+
parts = (
|
| 46 |
+
"".join(secrets.choice(alphabet) for _ in range(3)),
|
| 47 |
+
"".join(secrets.choice(alphabet) for _ in range(4)),
|
| 48 |
+
"".join(secrets.choice(alphabet) for _ in range(3)),
|
| 49 |
+
)
|
| 50 |
+
return f"https://meet.google.com/{'-'.join(parts)}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _build_message(
|
| 54 |
+
*,
|
| 55 |
+
sender: str,
|
| 56 |
+
candidate_name: str,
|
| 57 |
+
candidate_email: str,
|
| 58 |
+
interviewer_name: str,
|
| 59 |
+
interviewer_email: str,
|
| 60 |
+
interview_round: str,
|
| 61 |
+
scheduled_on: datetime,
|
| 62 |
+
end_time: datetime,
|
| 63 |
+
meet_url: str,
|
| 64 |
+
notes: str | None,
|
| 65 |
+
ics_content: str,
|
| 66 |
+
) -> EmailMessage:
|
| 67 |
+
msg = EmailMessage()
|
| 68 |
+
msg["Subject"] = f"{interview_round} interview: {candidate_name}"
|
| 69 |
+
msg["From"] = sender
|
| 70 |
+
msg["To"] = candidate_email
|
| 71 |
+
msg["Cc"] = interviewer_email
|
| 72 |
+
|
| 73 |
+
body_lines = [
|
| 74 |
+
f"Hi {candidate_name.split()[0] if candidate_name else 'there'},",
|
| 75 |
+
"",
|
| 76 |
+
f"Your {interview_round} interview with {interviewer_name} is scheduled for "
|
| 77 |
+
f"{scheduled_on.strftime('%A, %d %B %Y at %I:%M %p')} "
|
| 78 |
+
f"(ends {end_time.strftime('%I:%M %p')}).",
|
| 79 |
+
"",
|
| 80 |
+
f"Join the meeting: {meet_url}",
|
| 81 |
+
"",
|
| 82 |
+
"The calendar invite is attached as an .ics file — just open it to add the",
|
| 83 |
+
"event to your calendar. Reply to this email if you need to reschedule.",
|
| 84 |
+
]
|
| 85 |
+
if notes:
|
| 86 |
+
body_lines.extend(["", f"Notes from the recruiter: {notes}"])
|
| 87 |
+
body_lines.extend(["", "— Recruitment OS"])
|
| 88 |
+
msg.set_content("\n".join(body_lines))
|
| 89 |
+
|
| 90 |
+
msg.add_attachment(
|
| 91 |
+
ics_content.encode("utf-8"),
|
| 92 |
+
maintype="text",
|
| 93 |
+
subtype="calendar",
|
| 94 |
+
filename="interview.ics",
|
| 95 |
+
)
|
| 96 |
+
return msg
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def send_interview_invite(
|
| 100 |
+
*,
|
| 101 |
+
candidate_name: str,
|
| 102 |
+
candidate_email: str,
|
| 103 |
+
interviewer_name: str,
|
| 104 |
+
interviewer_email: str,
|
| 105 |
+
interview_round: str,
|
| 106 |
+
scheduled_on: datetime,
|
| 107 |
+
end_time: datetime,
|
| 108 |
+
notes: str | None,
|
| 109 |
+
ics_content: str,
|
| 110 |
+
meet_url: str | None = None,
|
| 111 |
+
) -> InviteSendResult:
|
| 112 |
+
settings = get_settings()
|
| 113 |
+
recipients = [candidate_email, interviewer_email]
|
| 114 |
+
meet = meet_url or generate_meet_url()
|
| 115 |
+
|
| 116 |
+
if not settings.has_smtp_config or not settings.smtp_from:
|
| 117 |
+
return InviteSendResult(
|
| 118 |
+
sent=False,
|
| 119 |
+
delivery="skipped",
|
| 120 |
+
detail=(
|
| 121 |
+
"SMTP credentials are not configured (SMTP_HOST/SMTP_USER/SMTP_PASSWORD/"
|
| 122 |
+
"SMTP_FROM). The invite was prepared but no email was actually sent. "
|
| 123 |
+
"Add SMTP variables to .env and restart the backend to enable auto-send."
|
| 124 |
+
),
|
| 125 |
+
meet_url=meet,
|
| 126 |
+
recipients=recipients,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
message = _build_message(
|
| 131 |
+
sender=settings.smtp_from,
|
| 132 |
+
candidate_name=candidate_name,
|
| 133 |
+
candidate_email=candidate_email,
|
| 134 |
+
interviewer_name=interviewer_name,
|
| 135 |
+
interviewer_email=interviewer_email,
|
| 136 |
+
interview_round=interview_round,
|
| 137 |
+
scheduled_on=scheduled_on,
|
| 138 |
+
end_time=end_time,
|
| 139 |
+
meet_url=meet,
|
| 140 |
+
notes=notes,
|
| 141 |
+
ics_content=ics_content,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if settings.smtp_use_tls:
|
| 145 |
+
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
| 146 |
+
server.starttls()
|
| 147 |
+
if settings.smtp_user and settings.smtp_password:
|
| 148 |
+
server.login(settings.smtp_user, settings.smtp_password)
|
| 149 |
+
server.send_message(message, to_addrs=recipients)
|
| 150 |
+
else:
|
| 151 |
+
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
| 152 |
+
if settings.smtp_user and settings.smtp_password:
|
| 153 |
+
server.login(settings.smtp_user, settings.smtp_password)
|
| 154 |
+
server.send_message(message, to_addrs=recipients)
|
| 155 |
+
|
| 156 |
+
return InviteSendResult(
|
| 157 |
+
sent=True,
|
| 158 |
+
delivery="smtp",
|
| 159 |
+
detail=f"Email + .ics + Google Meet link sent to {', '.join(recipients)}.",
|
| 160 |
+
meet_url=meet,
|
| 161 |
+
recipients=recipients,
|
| 162 |
+
)
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
logger.exception("SMTP send failed")
|
| 165 |
+
return InviteSendResult(
|
| 166 |
+
sent=False,
|
| 167 |
+
delivery="failed",
|
| 168 |
+
detail=f"SMTP attempt failed: {exc}",
|
| 169 |
+
meet_url=meet,
|
| 170 |
+
recipients=recipients,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@dataclass
|
| 175 |
+
class GenericSendResult:
|
| 176 |
+
sent: bool
|
| 177 |
+
delivery: str
|
| 178 |
+
detail: str
|
| 179 |
+
recipients: list[str]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def send_plain_email(
|
| 183 |
+
*,
|
| 184 |
+
to: list[str],
|
| 185 |
+
subject: str,
|
| 186 |
+
body: str,
|
| 187 |
+
cc: list[str] | None = None,
|
| 188 |
+
) -> GenericSendResult:
|
| 189 |
+
"""Send a plain-text email using the configured SMTP settings."""
|
| 190 |
+
settings = get_settings()
|
| 191 |
+
recipients = [addr for addr in (to or []) if addr]
|
| 192 |
+
cc_list = [addr for addr in (cc or []) if addr]
|
| 193 |
+
all_recipients = recipients + cc_list
|
| 194 |
+
|
| 195 |
+
if not all_recipients:
|
| 196 |
+
return GenericSendResult(
|
| 197 |
+
sent=False,
|
| 198 |
+
delivery="error",
|
| 199 |
+
detail="At least one recipient (to) is required.",
|
| 200 |
+
recipients=[],
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
if not settings.has_smtp_config or not settings.smtp_from:
|
| 204 |
+
return GenericSendResult(
|
| 205 |
+
sent=False,
|
| 206 |
+
delivery="skipped",
|
| 207 |
+
detail=(
|
| 208 |
+
"SMTP credentials are not configured. Add SMTP_HOST/SMTP_USER/"
|
| 209 |
+
"SMTP_PASSWORD/SMTP_FROM in .env and restart the backend."
|
| 210 |
+
),
|
| 211 |
+
recipients=all_recipients,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
message = EmailMessage()
|
| 216 |
+
message["Subject"] = subject
|
| 217 |
+
message["From"] = settings.smtp_from
|
| 218 |
+
message["To"] = ", ".join(recipients)
|
| 219 |
+
if cc_list:
|
| 220 |
+
message["Cc"] = ", ".join(cc_list)
|
| 221 |
+
message.set_content(body)
|
| 222 |
+
|
| 223 |
+
if settings.smtp_use_tls:
|
| 224 |
+
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
| 225 |
+
server.starttls()
|
| 226 |
+
if settings.smtp_user and settings.smtp_password:
|
| 227 |
+
server.login(settings.smtp_user, settings.smtp_password)
|
| 228 |
+
server.send_message(message, to_addrs=all_recipients)
|
| 229 |
+
else:
|
| 230 |
+
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
| 231 |
+
if settings.smtp_user and settings.smtp_password:
|
| 232 |
+
server.login(settings.smtp_user, settings.smtp_password)
|
| 233 |
+
server.send_message(message, to_addrs=all_recipients)
|
| 234 |
+
|
| 235 |
+
return GenericSendResult(
|
| 236 |
+
sent=True,
|
| 237 |
+
delivery="smtp",
|
| 238 |
+
detail=f"Email delivered to {', '.join(all_recipients)}.",
|
| 239 |
+
recipients=all_recipients,
|
| 240 |
+
)
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
logger.exception("SMTP send failed")
|
| 243 |
+
return GenericSendResult(
|
| 244 |
+
sent=False,
|
| 245 |
+
delivery="failed",
|
| 246 |
+
detail=f"SMTP attempt failed: {exc}",
|
| 247 |
+
recipients=all_recipients,
|
| 248 |
+
)
|
backend/services/reference_data_service.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import shutil
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
|
| 10 |
+
from core.db import SessionLocal
|
| 11 |
+
from core.models import Candidate
|
| 12 |
+
from services.ingestion_service import IngestionService
|
| 13 |
+
|
| 14 |
+
DEFAULT_REFERENCE_RESUMES_DIR = (
|
| 15 |
+
Path(__file__).resolve().parents[1]
|
| 16 |
+
/ "data"
|
| 17 |
+
/ "reference_resumes"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
FALLBACK_REFERENCE_RESUME_DIRS = [
|
| 21 |
+
Path("E:/SaturnIQ/engazeiq-adk")
|
| 22 |
+
/ "multiagent_platform_backend"
|
| 23 |
+
/ "services"
|
| 24 |
+
/ "agent_services"
|
| 25 |
+
/ "mcp_tools"
|
| 26 |
+
/ "organized_docs"
|
| 27 |
+
/ "resumes",
|
| 28 |
+
Path("E:/SaturnIQ/engazeiq-adk")
|
| 29 |
+
/ "multiagent_platform_backend"
|
| 30 |
+
/ "services"
|
| 31 |
+
/ "agent_services"
|
| 32 |
+
/ "mcp_tools"
|
| 33 |
+
/ "organized_docs"
|
| 34 |
+
/ "resumes_",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class ReferenceIngestionSummary:
|
| 40 |
+
source_dir: str
|
| 41 |
+
requested_limit: int
|
| 42 |
+
files_seen: int
|
| 43 |
+
ingested: int
|
| 44 |
+
skipped: int
|
| 45 |
+
failed: int
|
| 46 |
+
failures: list[dict[str, str]]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _resolve_source_dir(source_dir: str | None) -> Path:
|
| 50 |
+
if source_dir:
|
| 51 |
+
explicit = Path(source_dir)
|
| 52 |
+
if explicit.exists() and explicit.is_dir():
|
| 53 |
+
return explicit
|
| 54 |
+
raise FileNotFoundError(f"Reference resume directory not found: {explicit}")
|
| 55 |
+
|
| 56 |
+
candidate_dirs = [DEFAULT_REFERENCE_RESUMES_DIR, *FALLBACK_REFERENCE_RESUME_DIRS]
|
| 57 |
+
for directory in candidate_dirs:
|
| 58 |
+
if directory.exists() and directory.is_dir() and any(directory.rglob("*.pdf")):
|
| 59 |
+
return directory
|
| 60 |
+
|
| 61 |
+
checked = "\n".join(str(path) for path in candidate_dirs)
|
| 62 |
+
raise FileNotFoundError(f"No usable reference resume directory found. Checked:\n{checked}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _normalize_external_id(file_path: Path) -> str:
|
| 66 |
+
normalized = re.sub(r"[^a-zA-Z0-9]+", "_", file_path.stem).strip("_")
|
| 67 |
+
if not normalized:
|
| 68 |
+
normalized = "resume"
|
| 69 |
+
return f"ref_{normalized.lower()}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def sync_reference_resumes_to_local(
|
| 73 |
+
*,
|
| 74 |
+
max_files: int = 120,
|
| 75 |
+
source_dir: str | None = None,
|
| 76 |
+
) -> dict[str, object]:
|
| 77 |
+
resolved_source = _resolve_source_dir(source_dir)
|
| 78 |
+
DEFAULT_REFERENCE_RESUMES_DIR.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
|
| 80 |
+
copied = 0
|
| 81 |
+
skipped = 0
|
| 82 |
+
seen_names: set[str] = set()
|
| 83 |
+
|
| 84 |
+
pdf_files = sorted(resolved_source.rglob("*.pdf"))[: max(1, max_files)]
|
| 85 |
+
for idx, file_path in enumerate(pdf_files, start=1):
|
| 86 |
+
base_name = file_path.name
|
| 87 |
+
target_name = base_name
|
| 88 |
+
|
| 89 |
+
same_name_target = DEFAULT_REFERENCE_RESUMES_DIR / base_name
|
| 90 |
+
if same_name_target.exists() and same_name_target.stat().st_size == file_path.stat().st_size:
|
| 91 |
+
skipped += 1
|
| 92 |
+
seen_names.add(base_name.lower())
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
if target_name.lower() in seen_names or (DEFAULT_REFERENCE_RESUMES_DIR / target_name).exists():
|
| 96 |
+
target_name = f"{idx:04d}_{base_name}"
|
| 97 |
+
|
| 98 |
+
seen_names.add(target_name.lower())
|
| 99 |
+
target_path = DEFAULT_REFERENCE_RESUMES_DIR / target_name
|
| 100 |
+
|
| 101 |
+
if target_path.exists():
|
| 102 |
+
skipped += 1
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
shutil.copy2(file_path, target_path)
|
| 106 |
+
copied += 1
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"source_dir": str(resolved_source),
|
| 110 |
+
"target_dir": str(DEFAULT_REFERENCE_RESUMES_DIR),
|
| 111 |
+
"files_seen": len(pdf_files),
|
| 112 |
+
"copied": copied,
|
| 113 |
+
"skipped": skipped,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def ingest_reference_resumes(
|
| 118 |
+
*,
|
| 119 |
+
limit: int = 25,
|
| 120 |
+
source_dir: str | None = None,
|
| 121 |
+
force_reingest: bool = False,
|
| 122 |
+
) -> ReferenceIngestionSummary:
|
| 123 |
+
base_dir = _resolve_source_dir(source_dir)
|
| 124 |
+
|
| 125 |
+
service = IngestionService()
|
| 126 |
+
pdf_files = sorted(base_dir.rglob("*.pdf"))[: max(1, limit)]
|
| 127 |
+
|
| 128 |
+
failures: list[dict[str, str]] = []
|
| 129 |
+
ingested = 0
|
| 130 |
+
skipped = 0
|
| 131 |
+
|
| 132 |
+
with SessionLocal() as db:
|
| 133 |
+
for pdf in pdf_files:
|
| 134 |
+
external_id = _normalize_external_id(pdf)
|
| 135 |
+
|
| 136 |
+
if not force_reingest:
|
| 137 |
+
exists = db.execute(
|
| 138 |
+
select(Candidate.id).where(Candidate.external_id == external_id)
|
| 139 |
+
).scalar_one_or_none()
|
| 140 |
+
if exists is not None:
|
| 141 |
+
skipped += 1
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
file_bytes = pdf.read_bytes()
|
| 146 |
+
service.ingest_pdf(
|
| 147 |
+
db,
|
| 148 |
+
file_name=pdf.name,
|
| 149 |
+
file_bytes=file_bytes,
|
| 150 |
+
external_id=external_id,
|
| 151 |
+
)
|
| 152 |
+
ingested += 1
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
failures.append({"file": str(pdf), "error": str(exc)})
|
| 155 |
+
|
| 156 |
+
return ReferenceIngestionSummary(
|
| 157 |
+
source_dir=str(base_dir),
|
| 158 |
+
requested_limit=max(1, limit),
|
| 159 |
+
files_seen=len(pdf_files),
|
| 160 |
+
ingested=ingested,
|
| 161 |
+
skipped=skipped,
|
| 162 |
+
failed=len(failures),
|
| 163 |
+
failures=failures,
|
| 164 |
+
)
|
backend/services/search_service.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from qdrant_client import QdrantClient
|
| 9 |
+
from qdrant_client.http.models import Distance, PointStruct, VectorParams
|
| 10 |
+
|
| 11 |
+
from core.config import get_settings
|
| 12 |
+
from services.embedding_service import EmbeddingService
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class QdrantSearchService:
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
self.settings = get_settings()
|
| 20 |
+
self.embedding_service = EmbeddingService()
|
| 21 |
+
self._client: QdrantClient | None = None
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def is_ready(self) -> bool:
|
| 25 |
+
return self.settings.has_qdrant_config and self.embedding_service.is_ready
|
| 26 |
+
|
| 27 |
+
def upsert_document_chunks(
|
| 28 |
+
self,
|
| 29 |
+
*,
|
| 30 |
+
candidate_id: str,
|
| 31 |
+
chunks: list[str],
|
| 32 |
+
metadata: dict[str, Any],
|
| 33 |
+
) -> int:
|
| 34 |
+
if not self.is_ready:
|
| 35 |
+
logger.warning("Skipping vector upsert because Qdrant or embeddings are not configured.")
|
| 36 |
+
return 0
|
| 37 |
+
|
| 38 |
+
vectors = [self.embedding_service.embed_document(chunk) for chunk in chunks]
|
| 39 |
+
vector_size = len(vectors[0]) if vectors else 0
|
| 40 |
+
if vector_size == 0:
|
| 41 |
+
return 0
|
| 42 |
+
|
| 43 |
+
self._ensure_collection(vector_size)
|
| 44 |
+
|
| 45 |
+
points: list[PointStruct] = []
|
| 46 |
+
for idx, (chunk, vector) in enumerate(zip(chunks, vectors, strict=False)):
|
| 47 |
+
payload = {
|
| 48 |
+
"candidate_id": candidate_id,
|
| 49 |
+
"chunk_text": chunk,
|
| 50 |
+
"chunk_index": idx,
|
| 51 |
+
**metadata,
|
| 52 |
+
}
|
| 53 |
+
points.append(
|
| 54 |
+
PointStruct(
|
| 55 |
+
id=str(uuid.uuid4()),
|
| 56 |
+
vector=vector,
|
| 57 |
+
payload=payload,
|
| 58 |
+
)
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
client = self._get_client()
|
| 62 |
+
client.upsert(collection_name=self.settings.qdrant_collection, points=points)
|
| 63 |
+
return len(points)
|
| 64 |
+
|
| 65 |
+
def semantic_search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
| 66 |
+
if not self.is_ready:
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
query_vector = self.embedding_service.embed_query(query)
|
| 70 |
+
client = self._get_client()
|
| 71 |
+
hits = client.search(
|
| 72 |
+
collection_name=self.settings.qdrant_collection,
|
| 73 |
+
query_vector=query_vector,
|
| 74 |
+
limit=top_k,
|
| 75 |
+
with_payload=True,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
grouped: dict[str, dict[str, Any]] = defaultdict(dict)
|
| 79 |
+
for hit in hits:
|
| 80 |
+
payload = hit.payload or {}
|
| 81 |
+
candidate_id = str(payload.get("candidate_id") or "")
|
| 82 |
+
if not candidate_id:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
previous = grouped.get(candidate_id)
|
| 86 |
+
chunk_text = str(payload.get("chunk_text") or "")
|
| 87 |
+
if not previous or hit.score > previous.get("score", 0.0):
|
| 88 |
+
grouped[candidate_id] = {
|
| 89 |
+
"candidate_id": candidate_id,
|
| 90 |
+
"score": float(hit.score),
|
| 91 |
+
"best_chunk": chunk_text,
|
| 92 |
+
"location": payload.get("location"),
|
| 93 |
+
"skills": payload.get("skills"),
|
| 94 |
+
"name": payload.get("full_name"),
|
| 95 |
+
"years_experience": payload.get("years_experience"),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
results = sorted(grouped.values(), key=lambda item: item["score"], reverse=True)
|
| 99 |
+
return results[:top_k]
|
| 100 |
+
|
| 101 |
+
def build_genui_payload(self, query: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
|
| 102 |
+
cards = []
|
| 103 |
+
chart_data = []
|
| 104 |
+
|
| 105 |
+
for row in rows:
|
| 106 |
+
chart_data.append(
|
| 107 |
+
{
|
| 108 |
+
"name": row.get("name") or row["candidate_id"][:8],
|
| 109 |
+
"score": round(float(row.get("score") or 0.0) * 100, 2),
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
cards.append(
|
| 113 |
+
{
|
| 114 |
+
"title": row.get("name") or row["candidate_id"],
|
| 115 |
+
"subtitle": row.get("best_chunk", "")[:180],
|
| 116 |
+
"tags": [
|
| 117 |
+
token.strip()
|
| 118 |
+
for token in str(row.get("skills") or "").split(",")
|
| 119 |
+
if token.strip()
|
| 120 |
+
][:4],
|
| 121 |
+
"meta": {
|
| 122 |
+
"score": round(float(row.get("score") or 0.0) * 100, 2),
|
| 123 |
+
"location": row.get("location"),
|
| 124 |
+
"years_experience": row.get("years_experience"),
|
| 125 |
+
},
|
| 126 |
+
"actions": [
|
| 127 |
+
{"label": "Open Profile", "action": f"open_profile:{row['candidate_id']}"},
|
| 128 |
+
],
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
"summary": f"Found {len(rows)} candidate matches for query: {query}",
|
| 134 |
+
"cards": cards,
|
| 135 |
+
"chart": {
|
| 136 |
+
"type": "bar",
|
| 137 |
+
"title": "Match Scores",
|
| 138 |
+
"xKey": "name",
|
| 139 |
+
"yKey": "score",
|
| 140 |
+
"data": chart_data,
|
| 141 |
+
},
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
def _ensure_collection(self, vector_size: int) -> None:
|
| 145 |
+
client = self._get_client()
|
| 146 |
+
collection = self.settings.qdrant_collection
|
| 147 |
+
if client.collection_exists(collection_name=collection):
|
| 148 |
+
return
|
| 149 |
+
|
| 150 |
+
client.create_collection(
|
| 151 |
+
collection_name=collection,
|
| 152 |
+
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
def _get_client(self) -> QdrantClient:
|
| 156 |
+
if self._client is None:
|
| 157 |
+
self._client = QdrantClient(
|
| 158 |
+
url=self.settings.qdrant_url,
|
| 159 |
+
api_key=self.settings.qdrant_api_key,
|
| 160 |
+
timeout=30,
|
| 161 |
+
)
|
| 162 |
+
return self._client
|
backend/services/storage_service.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generic S3-compatible object storage for resume PDFs.
|
| 2 |
+
|
| 3 |
+
Drop-in support for any S3-compatible service:
|
| 4 |
+
|
| 5 |
+
* **Supabase Storage** — set ``S3_ENDPOINT=https://<project>.supabase.co/storage/v1/s3``
|
| 6 |
+
and use the project's ``service_role`` key as both the access key id and the
|
| 7 |
+
secret key.
|
| 8 |
+
* **Backblaze B2** — endpoint ``https://s3.<region>.backblazeb2.com``.
|
| 9 |
+
* **AWS S3** — leave the endpoint blank (boto3 picks the default), set the
|
| 10 |
+
region to your bucket's region.
|
| 11 |
+
* **MinIO** — point at your MinIO endpoint.
|
| 12 |
+
|
| 13 |
+
All four drive the same code path; only the env vars change.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
from pathlib import PurePosixPath
|
| 20 |
+
|
| 21 |
+
import boto3
|
| 22 |
+
from botocore.client import BaseClient
|
| 23 |
+
|
| 24 |
+
from core.config import get_settings
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ObjectStorageService:
|
| 30 |
+
def __init__(self) -> None:
|
| 31 |
+
self.settings = get_settings()
|
| 32 |
+
self._client: BaseClient | None = None
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def is_ready(self) -> bool:
|
| 36 |
+
return self.settings.has_s3_config
|
| 37 |
+
|
| 38 |
+
def upload_resume(self, file_name: str, file_bytes: bytes) -> tuple[str | None, str | None]:
|
| 39 |
+
if not self.is_ready:
|
| 40 |
+
return None, None
|
| 41 |
+
|
| 42 |
+
object_key = self._build_object_key(file_name)
|
| 43 |
+
client = self._get_client()
|
| 44 |
+
|
| 45 |
+
client.put_object(
|
| 46 |
+
Bucket=self.settings.s3_bucket,
|
| 47 |
+
Key=object_key,
|
| 48 |
+
Body=file_bytes,
|
| 49 |
+
ContentType="application/pdf",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
public_url = self._build_public_url(object_key)
|
| 53 |
+
return object_key, public_url
|
| 54 |
+
|
| 55 |
+
def _get_client(self) -> BaseClient:
|
| 56 |
+
if self._client is None:
|
| 57 |
+
kwargs: dict = {
|
| 58 |
+
"aws_access_key_id": self.settings.s3_access_key_id,
|
| 59 |
+
"aws_secret_access_key": self.settings.s3_secret_access_key,
|
| 60 |
+
"region_name": self.settings.s3_region,
|
| 61 |
+
}
|
| 62 |
+
if self.settings.s3_endpoint:
|
| 63 |
+
kwargs["endpoint_url"] = self.settings.s3_endpoint
|
| 64 |
+
self._client = boto3.client("s3", **kwargs)
|
| 65 |
+
return self._client
|
| 66 |
+
|
| 67 |
+
def _build_public_url(self, object_key: str) -> str:
|
| 68 |
+
# If the provider gives a separate public/CDN URL, prefer that.
|
| 69 |
+
if self.settings.s3_public_base_url:
|
| 70 |
+
return f"{self.settings.s3_public_base_url.rstrip('/')}/{object_key}"
|
| 71 |
+
if self.settings.s3_endpoint:
|
| 72 |
+
return f"{self.settings.s3_endpoint.rstrip('/')}/{self.settings.s3_bucket}/{object_key}"
|
| 73 |
+
return f"https://{self.settings.s3_bucket}.s3.amazonaws.com/{object_key}"
|
| 74 |
+
|
| 75 |
+
def _build_object_key(self, file_name: str) -> str:
|
| 76 |
+
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
| 77 |
+
safe_name = file_name.replace(" ", "_")
|
| 78 |
+
return str(PurePosixPath("resumes") / f"{stamp}_{safe_name}")
|
frontend/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
BACKEND_URL=http://127.0.0.1:7860
|
frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
|
| 4 |
+
// NOTE: This file should not be edited
|
| 5 |
+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
experimental: {
|
| 5 |
+
serverActions: {
|
| 6 |
+
bodySizeLimit: "10mb"
|
| 7 |
+
}
|
| 8 |
+
}
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
module.exports = nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "recruitment-genui-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 3000",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start -p 3000",
|
| 9 |
+
"lint": "next lint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@crayonai/react-core": "latest",
|
| 13 |
+
"@crayonai/react-ui": "latest",
|
| 14 |
+
"@crayonai/stream": "latest",
|
| 15 |
+
"@react-three/drei": "^9.122.0",
|
| 16 |
+
"@react-three/fiber": "^8.17.10",
|
| 17 |
+
"@thesysai/genui-sdk": "latest",
|
| 18 |
+
"next": "14.2.29",
|
| 19 |
+
"react": "18.3.1",
|
| 20 |
+
"react-dom": "18.3.1",
|
| 21 |
+
"react-markdown": "^9.1.0",
|
| 22 |
+
"recharts": "2.15.3",
|
| 23 |
+
"remark-gfm": "^4.0.1",
|
| 24 |
+
"three": "^0.164.1"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@types/node": "20.17.50",
|
| 28 |
+
"@types/react": "18.3.20",
|
| 29 |
+
"@types/react-dom": "18.3.7",
|
| 30 |
+
"eslint": "8.57.1",
|
| 31 |
+
"eslint-config-next": "14.2.29",
|
| 32 |
+
"typescript": "5.8.3"
|
| 33 |
+
}
|
| 34 |
+
}
|
frontend/src/app/api/candidates/[id]/route.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest } from "next/server";
|
| 2 |
+
|
| 3 |
+
export const runtime = "nodejs";
|
| 4 |
+
|
| 5 |
+
export async function GET(
|
| 6 |
+
_request: NextRequest,
|
| 7 |
+
{ params }: { params: { id: string } }
|
| 8 |
+
) {
|
| 9 |
+
const backendBaseUrl = process.env.BACKEND_URL ?? "http://127.0.0.1:7860";
|
| 10 |
+
const upstream = await fetch(
|
| 11 |
+
`${backendBaseUrl}/api/candidates/${encodeURIComponent(params.id)}`,
|
| 12 |
+
{ cache: "no-store" }
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
const payload = await upstream.text();
|
| 16 |
+
return new Response(payload, {
|
| 17 |
+
status: upstream.status,
|
| 18 |
+
headers: {
|
| 19 |
+
"Content-Type": upstream.headers.get("content-type") ?? "application/json"
|
| 20 |
+
}
|
| 21 |
+
});
|
| 22 |
+
}
|
frontend/src/app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest } from "next/server";
|
| 2 |
+
|
| 3 |
+
export const runtime = "nodejs";
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
const backendBaseUrl = process.env.BACKEND_URL ?? "http://127.0.0.1:7860";
|
| 7 |
+
const body = await request.text();
|
| 8 |
+
|
| 9 |
+
const upstream = await fetch(`${backendBaseUrl}/api/chat`, {
|
| 10 |
+
method: "POST",
|
| 11 |
+
headers: {
|
| 12 |
+
"Content-Type": "application/json"
|
| 13 |
+
},
|
| 14 |
+
body,
|
| 15 |
+
cache: "no-store"
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
if (!upstream.ok || !upstream.body) {
|
| 19 |
+
const text = await upstream.text();
|
| 20 |
+
return new Response(text || "Upstream chat request failed.", {
|
| 21 |
+
status: upstream.status || 500
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return new Response(upstream.body, {
|
| 26 |
+
status: 200,
|
| 27 |
+
headers: {
|
| 28 |
+
"Content-Type": "text/event-stream",
|
| 29 |
+
"Cache-Control": "no-cache, no-transform",
|
| 30 |
+
Connection: "keep-alive"
|
| 31 |
+
}
|
| 32 |
+
});
|
| 33 |
+
}
|
frontend/src/app/api/upload/route.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest } from "next/server";
|
| 2 |
+
|
| 3 |
+
export const runtime = "nodejs";
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
const backendBaseUrl = process.env.BACKEND_URL ?? "http://127.0.0.1:7860";
|
| 7 |
+
const formData = await request.formData();
|
| 8 |
+
const sessionId = request.nextUrl.searchParams.get("session_id") ?? "";
|
| 9 |
+
|
| 10 |
+
const targetUrl = sessionId
|
| 11 |
+
? `${backendBaseUrl}/api/upload?session_id=${encodeURIComponent(sessionId)}`
|
| 12 |
+
: `${backendBaseUrl}/api/upload`;
|
| 13 |
+
|
| 14 |
+
const upstream = await fetch(targetUrl, {
|
| 15 |
+
method: "POST",
|
| 16 |
+
body: formData,
|
| 17 |
+
cache: "no-store"
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const payload = await upstream.text();
|
| 21 |
+
return new Response(payload, {
|
| 22 |
+
status: upstream.status,
|
| 23 |
+
headers: {
|
| 24 |
+
"Content-Type": upstream.headers.get("content-type") ?? "application/json"
|
| 25 |
+
}
|
| 26 |
+
});
|
| 27 |
+
}
|