Ashgen12 commited on
Commit
14fdc5e
·
verified ·
1 Parent(s): cee0a62

Recruitment Copilot

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +58 -0
  2. Dockerfile +34 -0
  3. LICENSE +21 -0
  4. README.md +178 -3
  5. backend/Dockerfile +17 -0
  6. backend/agent/__init__.py +1 -0
  7. backend/agent/agent.py +114 -0
  8. backend/agent/runtime.py +225 -0
  9. backend/core/__init__.py +1 -0
  10. backend/core/config.py +110 -0
  11. backend/core/db.py +34 -0
  12. backend/core/models.py +91 -0
  13. backend/core/schemas.py +42 -0
  14. backend/core/utils.py +266 -0
  15. backend/data/policies.json +7 -0
  16. backend/data/seed_candidates.py +69 -0
  17. backend/main.py +439 -0
  18. backend/mcp_server/__init__.py +1 -0
  19. backend/mcp_server/router.py +43 -0
  20. backend/mcp_server/server.py +74 -0
  21. backend/mcp_server/tools/__init__.py +1 -0
  22. backend/mcp_server/tools/application_status.py +155 -0
  23. backend/mcp_server/tools/candidate_query.py +31 -0
  24. backend/mcp_server/tools/email_compose.py +243 -0
  25. backend/mcp_server/tools/interview_records.py +394 -0
  26. backend/mcp_server/tools/job_matching.py +58 -0
  27. backend/mcp_server/tools/job_posting.py +202 -0
  28. backend/mcp_server/tools/pdf_ingest.py +40 -0
  29. backend/mcp_server/tools/policy_info.py +195 -0
  30. backend/mcp_server/tools/reference_resumes.py +45 -0
  31. backend/mcp_server/tools/vector_search.py +27 -0
  32. backend/requirements.txt +14 -0
  33. backend/scripts/load_reference_resumes.py +53 -0
  34. backend/scripts/sync_reference_resumes.py +44 -0
  35. backend/services/__init__.py +1 -0
  36. backend/services/candidate_service.py +414 -0
  37. backend/services/embedding_service.py +43 -0
  38. backend/services/ingestion_service.py +270 -0
  39. backend/services/notification_service.py +248 -0
  40. backend/services/reference_data_service.py +164 -0
  41. backend/services/search_service.py +162 -0
  42. backend/services/storage_service.py +78 -0
  43. frontend/.env.example +1 -0
  44. frontend/next-env.d.ts +5 -0
  45. frontend/next.config.js +11 -0
  46. frontend/package-lock.json +0 -0
  47. frontend/package.json +34 -0
  48. frontend/src/app/api/candidates/[id]/route.ts +22 -0
  49. frontend/src/app/api/chat/route.ts +33 -0
  50. 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: pink
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("&", "&amp;")
248
+ .replace("<", "&lt;")
249
+ .replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
+ }