diff --git a/.gitignore b/.gitignore index 1701bca..109e226 100644 --- a/.gitignore +++ b/.gitignore @@ -181,4 +181,6 @@ logs/ client_secret_*.json *-service-account*.json project-*.json -backend/credentials/ \ No newline at end of file +backend/credentials/ +**:Zone.Identifier +backend/tests/output/ \ No newline at end of file diff --git a/backend/app/api/v1/jobs.py b/backend/app/api/v1/jobs.py index 31b988d..e93a724 100644 --- a/backend/app/api/v1/jobs.py +++ b/backend/app/api/v1/jobs.py @@ -67,6 +67,9 @@ async def tailor_resume( job_id=job.id, user_id=current_user.id, user_email=current_user.email, github_profile=github_profile, resume_gcs_path=resume.gcs_path, job_url=req.linkedin_job_url, + resume_id=resume.id, + user_name=f"{current_user.first_name} {current_user.last_name}", + resume_filename=resume.original_filename, ) ) return job @@ -183,6 +186,9 @@ async def retry_job( job_id=job.id, user_id=current_user.id, user_email=current_user.email, github_profile=github_profile, resume_gcs_path=resume.gcs_path, job_url=job.linkedin_job_url, + resume_id=resume.id, + user_name=f"{current_user.first_name} {current_user.last_name}", + resume_filename=resume.original_filename, ) ) return job diff --git a/backend/app/api/v1/resumes.py b/backend/app/api/v1/resumes.py index c6a0616..0f022f5 100644 --- a/backend/app/api/v1/resumes.py +++ b/backend/app/api/v1/resumes.py @@ -4,6 +4,8 @@ Endpoints for uploading, listing, previewing, and deleting resumes. """ +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +20,8 @@ settings = get_settings() router = APIRouter(prefix="/api/resumes", tags=["resumes"]) +ALLOWED_RESUME_EXTENSIONS = {".md", ".pdf", ".docx"} + @router.post("/upload", response_model=ResumeResponse, status_code=status.HTTP_201_CREATED) async def upload_resume_file( @@ -25,12 +29,12 @@ async def upload_resume_file( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Upload a .md resume file to GCS.""" + """Upload a resume file (.md, .pdf or .docx) to GCS.""" # Validate file extension - if not file.filename or not file.filename.endswith(".md"): + if not file.filename or Path(file.filename).suffix.lower() not in ALLOWED_RESUME_EXTENSIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Only Markdown (.md) files are accepted.", + detail="Only Markdown (.md), PDF (.pdf) or Word (.docx) files are accepted.", ) # Read file content @@ -120,6 +124,15 @@ async def get_resume_content( if not resume: raise HTTPException(status_code=404, detail="Resume not found.") + # Binary resumes (.pdf/.docx) cannot be rendered as markdown text — + # the /preview signed-URL endpoint handles those. + if Path(resume.original_filename or "").suffix.lower() != ".md": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="In-browser text preview is only available for Markdown resumes. " + "Use the preview endpoint for PDF/DOCX files.", + ) + try: content_bytes = download_file(resume.gcs_path) return {"content": content_bytes.decode("utf-8"), "filename": resume.original_filename} diff --git a/backend/app/core/database.py b/backend/app/core/database.py index a80cade..6b4d1f7 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -5,6 +5,8 @@ Uses asyncmy driver for Cloud SQL MySQL connectivity. """ +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.orm import DeclarativeBase @@ -42,6 +44,27 @@ def _patched_ping(self, reconnect: bool = False) -> None: ) +# ── Sync engine (lazy) ──────────────────────────────────────────────────────── +# The async engine's pooled connections are bound to the main event loop, so +# synchronous code running in worker threads (e.g. CrewAI tools inside the +# crew_runner's ThreadPoolExecutor) must not use it. They get a small pymysql +# engine instead, created on first use. +_sync_engine: Engine | None = None + + +def get_sync_engine() -> Engine: + """Return the lazily-created synchronous engine (pymysql driver).""" + global _sync_engine + if _sync_engine is None: + _sync_engine = create_engine( + settings.mysql_url_sync, + pool_pre_ping=True, + pool_size=2, + max_overflow=2, + ) + return _sync_engine + + # ── Declarative Base ────────────────────────────────────────────────────────── class Base(DeclarativeBase): pass diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index 20f849d..0995cc9 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -79,7 +79,7 @@ def _setup_root_logger() -> None: backupCount=BACKUP_COUNT, encoding="utf-8", ) - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(getattr(logging, LOG_LEVEL_ENV, logging.INFO)) file_handler.setFormatter(file_fmt) # ── Console handler ─────────────────────────────────────────────────────── diff --git a/backend/app/main.py b/backend/app/main.py index 0801ddc..8094bf7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,23 @@ async def lifespan(app: FastAPI): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # Lightweight migration: create_all only creates missing tables, it + # never alters existing ones. Add resumes.parsed_resume_path (written + # by the resume_analyzer agent) if the column is missing. Idempotent + # and best-effort, same policy as the cleanup below. + try: + def _add_parsed_resume_column(sync_conn): + from sqlalchemy import inspect, text + columns = {c["name"] for c in inspect(sync_conn).get_columns("resumes")} + if "parsed_resume_path" not in columns: + sync_conn.execute(text( + "ALTER TABLE resumes ADD COLUMN parsed_resume_path VARCHAR(500) NULL" + )) + logger.info("Added resumes.parsed_resume_path column") + await conn.run_sync(_add_parsed_resume_column) + except Exception as exc: # noqa: BLE001 — never let migration break startup + logger.warning("Startup parsed_resume_path migration skipped: %s", exc) + # Best-effort: fail jobs left pending/processing for >3h (orphaned by a # prior restart). Reuses THIS connection — no new session/checkout — and # swallows all errors so it can never abort startup or kill the proxy. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 31ec2cb..81a1498 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -64,7 +64,11 @@ class Resume(Base): id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) original_filename = Column(String(255), nullable=False) - gcs_path = Column(String(500), nullable=False) # gs://bucket/uploads//.md + gcs_path = Column(String(500), nullable=False) # gs://bucket/uploads// + # gs://bucket/parsed//___parsed_resume.json + # Set by the resume_analyzer agent only after the JSON is written and + # uploaded to GCS successfully. + parsed_resume_path = Column(String(500), nullable=True) uploaded_at = Column(DateTime, default=datetime.utcnow) # Relationships diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 0654a88..5066889 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -64,6 +64,7 @@ class ResumeResponse(BaseModel): id: int original_filename: str gcs_path: str + parsed_resume_path: Optional[str] = None uploaded_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/services/crew/agents.py b/backend/app/services/crew/agents.py index acfe032..75b3e72 100644 --- a/backend/app/services/crew/agents.py +++ b/backend/app/services/crew/agents.py @@ -19,7 +19,7 @@ load_dotenv() logger = get_logger(__name__) -# LLM configuration — Claude Sonnet 4 +# LLM configuration — Gemini gemini_llm = LLM( model="gemini/gemini-2.5-flash", api_key=os.environ.get("GEMINI_API_KEY"), @@ -27,6 +27,15 @@ max_tokens=8000 ) +# Low-temperature variant for structured extraction (resume_analyzer): the +# parsed-resume JSON must be reproduced verbatim and can exceed 8k tokens. +gemini_llm_extraction = LLM( + model="gemini/gemini-2.5-flash", + api_key=os.environ.get("GEMINI_API_KEY"), + temperature=0.1, + max_tokens=16000 +) + search_tool = SerperDevTool() scrape_tool = ScrapeWebsiteTool() read_resume = FileReadTool() @@ -148,10 +157,11 @@ def repo_content_searcher(query: str, job_description: str = None, top_k: int = } for content, metadata in results ] -# Agent 1: GitHub Project Summarizer # NOTE: The Researcher agent has been removed. Job extraction is done # directly in Job_Applier.py via extract_linkedin_job_details() and the # structured JobDetails are passed into every task via crew input variables. + +# Agent 1: GitHub Project Summarizer github_project_summarizer = Agent( role="GitHub Project Summarizer", goal="Summarize the user's most relevant GitHub projects for a job application, highlighting tech stacks, languages, frameworks, tools, and cloud technologies used.", @@ -170,6 +180,36 @@ def repo_content_searcher(query: str, job_description: str = None, top_k: int = ) +# Agent 2: Resume Analyzer +# No agent-level tools: the per-run save_parsed_resume tool (closured over +# user/resume IDs) is attached at the Task level by build_tasks(), which keeps +# this module-level singleton thread-safe across concurrent crew runs. +resume_analyzer = Agent( + role="Resume Analyzer", + goal=( + "Carefully scan a resume and extract every piece of relevant applicant " + "information into a strictly valid JSON document matching the required schema." + ), + llm=gemini_llm_extraction, + verbose=True, + max_iter=8, + max_rpm=10, + respect_context_window=True, + backstory=( + "You are a meticulous structured-data extraction specialist for resumes. " + "You read every line of a resume and map each fact to the correct field of " + "a fixed JSON schema. You never invent, guess, or embellish information: " + "every value you output must be traceable to explicit text in the resume, " + "and anything the resume does not state is left as an empty string, null, an " + "empty list, or false, exactly as the schema prescribes. When the resume " + "groups skills under category headings you preserve those headings verbatim; " + "when it does not, you place the skills under a single \"Default\" category. " + "You always produce strictly valid JSON — no comments, no trailing commas, " + "no markdown fences — and you always persist your work with the " + "save_parsed_resume tool before finishing." + ) +) + # Agent 3: Profiler profiler = Agent( role="Personal Profiler for Engineers", diff --git a/backend/app/services/crew/crew.py b/backend/app/services/crew/crew.py index b0624c4..3f9764a 100644 --- a/backend/app/services/crew/crew.py +++ b/backend/app/services/crew/crew.py @@ -18,10 +18,12 @@ from app.core.logging import get_logger from app.services.crew.agents import ( github_project_summarizer, + resume_analyzer, profiler, resume_strategist, interview_preparer, ) +from app.services.crew.resume_tools import ResumeAnalysisContext from app.services.crew.tasks import build_tasks logger = get_logger(__name__) @@ -32,6 +34,7 @@ def build_crew( verbose: bool = True, tracing: bool = True, include_github: bool = True, + resume_ctx: ResumeAnalysisContext | None = None, ) -> Crew: """ Construct the job-application :class:`crewai.Crew`. @@ -39,6 +42,11 @@ def build_crew( Job details are extracted once by the caller and injected into every task via the ``job_details_json`` crew input variable, so no researcher agent is needed. + The resume analysis task and the GitHub summary task run in parallel + (``async_execution=True``); the profile task waits for both via its + ``context``. If resume analysis fails, ``kickoff()`` raises before any + downstream task runs. + Args: output_log_file: Path for CrewAI's verbose execution log. verbose: Enable verbose agent/task logging. @@ -46,15 +54,18 @@ def build_crew( include_github: When True, include the GitHub summarizer agent/task and require the ``github_url`` / ``bq_dataset_name`` crew inputs. When False, skip GitHub entirely and tailor from the resume + job details. + resume_ctx: Identity/state context for the resume analysis task's save + tool. When None (standalone CLI), the parsed resume JSON is written + to temp storage only — no GCS upload, no database write. Returns: A configured :class:`crewai.Crew` ready for ``kickoff(inputs=...)``. The task list order is stable: the interview task is always last and the resume task always second-to-last (``crew.tasks[-1]`` / ``[-2]``). """ - tasks = build_tasks(include_github=include_github) + tasks = build_tasks(include_github=include_github, resume_ctx=resume_ctx) - agents = [profiler, resume_strategist, interview_preparer] + agents = [resume_analyzer, profiler, resume_strategist, interview_preparer] if include_github: agents.insert(0, github_project_summarizer) diff --git a/backend/app/services/crew/resume_tools.py b/backend/app/services/crew/resume_tools.py new file mode 100644 index 0000000..d6cefcc --- /dev/null +++ b/backend/app/services/crew/resume_tools.py @@ -0,0 +1,357 @@ +""" +app.services.crew.resume_tools +------------------------------ +Per-run tool and guardrail factories for the resume_analyzer agent. + +The save tool is built fresh for every crew run (like the Task objects in +``app.services.crew.tasks``) so that user_id / resume_id / user_name are +closured into the tool rather than supplied by the LLM, and so a shared +``ResumeAnalysisContext.state`` dict can carry the outcome to the task +guardrail. The tool enforces the required ordering in code: + + validate JSON → write temp file → upload to GCS → update database + +The database is only touched after the file write and the GCS upload have +both succeeded. With ``local_only=True`` (isolation test script, CLI) the +tool stops after the temp-file write — no GCS, no database. + +CrewAI converts tool exceptions into error messages fed back to the LLM, so +this tool never relies on raising: every failure returns an "ERROR: ..." +string the agent can react to, and the task guardrail aborts the crew if no +successful save happened after the retry budget. +""" + +import json +import os +import tempfile +from dataclasses import dataclass, field +from typing import Any, Callable, Dict + +from crewai import TaskOutput +from crewai.tools import tool +from pydantic import ValidationError + +from app.core.logging import get_logger +from app.services.resume_parser import ( + ParsedResume, + build_parsed_resume_filename, + compute_years_of_experience, + find_invalid_url_fields, +) + +logger = get_logger(__name__) + + +@dataclass +class ResumeAnalysisContext: + """Per-run identity and shared state for the resume-analysis task.""" + + user_id: int + resume_id: int + user_name: str + local_only: bool = False + # The extracted resume text, used as the reference context for the + # hallucination guardrail so the agent cannot invent skills/positions/etc. + resume_text: str = "" + state: Dict[str, Any] = field(default_factory=dict) + + +def _strip_code_fences(text: str) -> str: + """Remove a surrounding ```json ... ``` / ``` ... ``` fence if present.""" + stripped = text.strip() + if stripped.startswith("```"): + first_newline = stripped.find("\n") + if first_newline != -1: + stripped = stripped[first_newline + 1:] + if stripped.rstrip().endswith("```"): + stripped = stripped.rstrip()[:-3] + return stripped.strip() + + +def make_save_parsed_resume_tool(ctx: ResumeAnalysisContext): + """Build the save_parsed_resume tool bound to one run's ``ctx``.""" + + @tool("save_parsed_resume") + def save_parsed_resume(parsed_resume_json: str) -> str: + """ + Persist the parsed resume JSON. Validates the JSON against the required + schema, writes it to a local file, uploads it to cloud storage, and + records the storage path in the database. Call this exactly once with + the COMPLETE parsed resume JSON string (the full {"id": ..., "result": + {...}} document). If the response starts with "ERROR:", fix the + reported problem and call the tool again with the corrected JSON. + + Args: + parsed_resume_json (str): The complete parsed resume JSON string. + + Returns: + str: "SUCCESS: ..." when everything was persisted, otherwise + "ERROR: ". + """ + raw = _strip_code_fences(parsed_resume_json) + + # 1. Validate against the required schema. + try: + parsed = ParsedResume.model_validate_json(raw) + except ValidationError as e: + logger.warning( + "resume_tools: invalid parsed-resume JSON for resume %d: %s", + ctx.resume_id, e, + ) + return ( + "ERROR: The JSON is invalid or does not match the required schema: " + f"{e}. Fix the JSON and call save_parsed_resume again with the " + "complete corrected document." + ) + + # 2. The id is not the LLM's to choose. + parsed.id = ctx.resume_id + + # 2a. Reject invalid URL fields so the agent re-extracts (or blanks them). + invalid_urls = find_invalid_url_fields(parsed.result) + if invalid_urls: + logger.warning( + "resume_tools: invalid URL field(s) %s for resume %d", + invalid_urls, ctx.resume_id, + ) + details = ", ".join( + f"{field}={getattr(parsed.result, field)!r}" for field in invalid_urls + ) + return ( + f"ERROR: These URL fields are not valid URLs: {details}. Every " + "*_url field must be a complete http(s) URL (e.g. " + "'https://www.linkedin.com/in/username') or an empty string \"\". " + "If the resume only shows the link's label/anchor text (like " + "'LinkedIn', 'Portfolio', 'Kaggle', or a name) and not the actual " + "URL, set that field to \"\". Fix these and call save_parsed_resume " + "again." + ) + + # 2b. Compute years_of_experience from position dates when the agent did + # not extract a value from the summary/objective (left it at 0). + if not parsed.result.years_of_experience: + computed = compute_years_of_experience(parsed.result) + if computed: + logger.info( + "resume_tools: computed years_of_experience=%d from positions " + "for resume %d", computed, ctx.resume_id, + ) + parsed.result.years_of_experience = computed + + normalized = parsed.model_dump_json(indent=2) + + # 3. Write to local temp storage. + filename = build_parsed_resume_filename(ctx.user_name, ctx.user_id, ctx.resume_id) + local_path = os.path.join(tempfile.gettempdir(), filename) + try: + with open(local_path, "w", encoding="utf-8") as f: + f.write(normalized) + except OSError as e: + logger.error( + "resume_tools: failed to write parsed resume to '%s': %s", + local_path, e, exc_info=True, + ) + return f"ERROR: Could not write the parsed resume file locally: {e}." + logger.info( + "resume_tools: parsed resume written to '%s' (%d bytes)", + local_path, len(normalized.encode("utf-8")), + ) + + if ctx.local_only: + ctx.state.update({"saved": True, "local_path": local_path, "json": normalized}) + logger.info( + "resume_tools: local_only run — skipping GCS upload and DB update " + "for resume %d", ctx.resume_id, + ) + return f"SUCCESS: Parsed resume saved locally to {local_path}." + + # 4. Upload to GCS. The database is not touched unless this succeeds. + try: + from app.services.gcs_service import upload_parsed_resume + + gcs_path = upload_parsed_resume(ctx.user_id, filename, normalized) + except Exception as e: # noqa: BLE001 — surface any GCS failure to the agent + logger.error( + "resume_tools: GCS upload of parsed resume for resume %d failed: %s", + ctx.resume_id, e, exc_info=True, + ) + return ( + "ERROR: The parsed resume file was created but the upload to cloud " + f"storage failed: {e}. Call save_parsed_resume again to retry." + ) + logger.info("resume_tools: parsed resume uploaded to %s", gcs_path) + + # 5. Record the GCS path on the Resume row (only after upload success). + try: + from sqlalchemy import update + from sqlalchemy.orm import Session + + from app.core.database import get_sync_engine + from app.models import Resume + + with Session(get_sync_engine()) as session: + session.execute( + update(Resume) + .where(Resume.id == ctx.resume_id) + .values(parsed_resume_path=gcs_path) + ) + session.commit() + except Exception as e: # noqa: BLE001 — surface any DB failure to the agent + logger.error( + "resume_tools: DB update of parsed_resume_path for resume %d failed: %s", + ctx.resume_id, e, exc_info=True, + ) + return ( + "ERROR: The parsed resume was uploaded but recording it in the " + f"database failed: {e}. Call save_parsed_resume again to retry." + ) + logger.info( + "resume_tools: resumes.parsed_resume_path set to '%s' for resume %d", + gcs_path, ctx.resume_id, + ) + + ctx.state.update({ + "saved": True, + "local_path": local_path, + "gcs_path": gcs_path, + "json": normalized, + }) + return ( + f"SUCCESS: Parsed resume saved to {gcs_path} and recorded in the database." + ) + + return save_parsed_resume + + +# Faithfulness score (0-10) the parsed JSON must meet against the resume text. +_HALLUCINATION_THRESHOLD = 7 + + +def _make_hallucination_guardrail(ctx: ResumeAnalysisContext): + """ + Build a CrewAI HallucinationGuardrail bound to this run's resume text, or + ``None`` when no reference text / LLM is available (e.g. the CLI path). + + The guardrail scores the parsed JSON's faithfulness against the extracted + resume text so the agent cannot invent skills, positions, or other facts. + + NOTE: in the open-source crewai package this guardrail is a pass-through + (it logs a notice and returns the output unchanged) — real faithfulness + scoring runs only on the CrewAI enterprise platform (app.crewai.com). The + wiring here is intentionally forward-compatible: it activates automatically + when running against enterprise, and never rejects a correctly-saved resume + on open source. + """ + if not ctx.resume_text: + return None + try: + from crewai.tasks.hallucination_guardrail import HallucinationGuardrail + + from app.services.crew.agents import gemini_llm_extraction + + return HallucinationGuardrail( + context=ctx.resume_text, + llm=gemini_llm_extraction, + threshold=_HALLUCINATION_THRESHOLD, + ) + except Exception as e: # noqa: BLE001 — never let guardrail setup break the run + logger.warning( + "resume_tools: could not build hallucination guardrail for resume %d: %s", + ctx.resume_id, e, + ) + return None + + +def make_parsed_resume_guardrail( + ctx: ResumeAnalysisContext, +) -> Callable[[TaskOutput], tuple[bool, Any]]: + """ + Build the resume-analysis task guardrail bound to one run's ``ctx``. + + A task's ``guardrail`` accepts a single value, so this composes three checks: + + 1. The save-tool invariant — the task passes only when save_parsed_resume + reported success. This replaces the task output with the normalized + JSON so downstream tasks (profiler) receive the exact persisted + document, and — on failure — is what aborts the crew after the retry + budget is exhausted (CrewAI raises, ``kickoff()`` propagates it). + 2. A URL-format check — every *_url field on the saved JSON must be a + valid http(s) URL or empty. (The save tool blocks invalid URLs before + it marks the run saved, so this is a secondary net.) + 3. A CrewAI HallucinationGuardrail — once the save is confirmed, the + normalized JSON is scored for faithfulness against the extracted + resume text so the agent cannot invent skills, positions, or other + facts. A failed check returns feedback for a retry; a guardrail + *error* (e.g. LLM unavailable) fails open so a transient outage does + not block a correctly-saved resume. + """ + hallucination_guardrail = _make_hallucination_guardrail(ctx) + + def parsed_resume_guardrail(task_output: TaskOutput) -> tuple[bool, Any]: + # 1. Save-tool invariant (load-bearing — aborts the crew on failure). + if not (ctx.state.get("saved") and ctx.state.get("json")): + logger.warning( + "resume_tools: guardrail rejected resume-analysis output for resume " + "%d — no successful save recorded", ctx.resume_id, + ) + return ( + False, + "The parsed resume was NOT saved. You MUST call the save_parsed_resume " + "tool with the complete parsed resume JSON and get a SUCCESS response " + "before producing your final answer. Fix any ERROR the tool reports " + "and try again.", + ) + + normalized = ctx.state["json"] + + # 2. URL-format check on the saved JSON (secondary net; the save tool + # already blocks invalid URLs). + try: + saved = ParsedResume.model_validate_json(normalized) + invalid_urls = find_invalid_url_fields(saved.result) + except ValidationError: + invalid_urls = [] + if invalid_urls: + logger.warning( + "resume_tools: guardrail found invalid URL field(s) %s for resume %d", + invalid_urls, ctx.resume_id, + ) + return ( + False, + f"These URL fields are not valid URLs: {', '.join(invalid_urls)}. " + "Every *_url field must be a complete http(s) URL or an empty " + "string \"\". If only the link's label/anchor text is visible (not " + "the actual URL), set that field to \"\". Fix these and call " + "save_parsed_resume again.", + ) + + # 3. Hallucination check against the resume text. + if hallucination_guardrail is not None: + try: + validated_output = TaskOutput( + description=task_output.description, + raw=normalized, + agent=task_output.agent, + ) + is_faithful, feedback = hallucination_guardrail(validated_output) + except Exception as e: # noqa: BLE001 — fail open on guardrail errors + logger.warning( + "resume_tools: hallucination guardrail errored for resume %d " + "(passing the saved output through): %s", ctx.resume_id, e, + ) + return True, normalized + if not is_faithful: + logger.warning( + "resume_tools: hallucination guardrail flagged resume %d: %s", + ctx.resume_id, feedback, + ) + return ( + False, + "The extracted data was not fully grounded in the resume. " + f"{feedback} Re-extract using ONLY facts present in the resume, " + "then call save_parsed_resume again with the corrected JSON.", + ) + + return True, normalized + + return parsed_resume_guardrail diff --git a/backend/app/services/crew/tasks.py b/backend/app/services/crew/tasks.py index 45fb352..f85b19c 100644 --- a/backend/app/services/crew/tasks.py +++ b/backend/app/services/crew/tasks.py @@ -1,6 +1,7 @@ from crewai import Task from app.services.crew.agents import ( github_project_summarizer, + resume_analyzer, profiler, resume_strategist, interview_preparer, @@ -9,6 +10,12 @@ read_resume, semantic_search_resume, ) +from app.services.crew.resume_tools import ( + ResumeAnalysisContext, + make_save_parsed_resume_tool, + make_parsed_resume_guardrail, +) +from app.services.resume_parser import PARSED_RESUME_SCHEMA # --------------------------------------------------------------------------- # NOTE: The researcher agent and research_task have been removed. @@ -26,20 +33,30 @@ # Building fresh Task objects per call (rather than reusing module-level # singletons) also makes per-request output_file overrides thread-safe under # the crew_runner's ThreadPoolExecutor. +# +# Parallelism: the GitHub summary task and the resume analysis task both run +# with async_execution=True. The (sync) profile task lists them in its +# ``context``, so CrewAI drains their futures — waiting for both and +# re-raising any failure — before profiling starts. A resume-analysis failure +# (guardrail retries exhausted) therefore aborts kickoff() before any +# downstream task runs. CrewAI validators require that the crew not END with +# more than one async task and that async tasks not reference each other in +# ``context``; the ordering below satisfies both. # --------------------------------------------------------------------------- # Description fragments toggled by include_github ------------------------------- # Profiler — step list ending differs depending on whether GitHub data exists. _PROFILE_GITHUB_STEPS = ( - " 3. Use the GitHub projects summary from the previous task as additional context.\n" - " 4. Synthesise all three sources into a comprehensive applicant profile." + " 4. Use the GitHub projects summary from the GitHub summary task as additional context.\n" + " 5. Synthesise all three sources into a comprehensive applicant profile." ) _PROFILE_NO_GITHUB_STEPS = ( - " 3. Synthesise the resume and job details into a comprehensive applicant profile.\n\n" + " 4. Synthesise the parsed resume and job details into a comprehensive applicant profile.\n\n" "IMPORTANT GROUNDING RULES:\n" " - No GitHub profile was provided for this applicant.\n" - " - Use ONLY information that appears in the resume and the job details above.\n" + " - Use ONLY information that appears in the parsed resume JSON, the raw resume, " + "and the job details above.\n" " - Do NOT invent, assume, or fabricate any projects, repositories, GitHub " "activity, employers, dates, metrics, or skills that are not present in the resume." ) @@ -57,8 +74,95 @@ ) +def _resume_analysis_task(ctx: ResumeAnalysisContext, *, run_async: bool = True) -> Task: + """ + Resume analysis task — extract the resume into the parsed-resume JSON and + persist it via the per-run save_parsed_resume tool. + + Runs with ``async_execution=True`` in the full crew so it executes in + parallel with the GitHub summary task; the isolation test script passes + ``run_async=False`` to run it standalone. + """ + schema = PARSED_RESUME_SCHEMA.replace("", str(ctx.resume_id)) + return Task( + # NOTE: this description embeds a JSON template. CrewAI interpolates + # bare {identifier} tokens in descriptions — the template's braces are + # all followed by quoted keys/whitespace so they are safe, but never + # add a bare {word} token here unless it is a real crew input. + description=( + "Carefully scan and analyze the applicant's resume and extract every " + "piece of relevant information into a structured JSON document.\n\n" + "Step 1 — Read the resume:\n" + " Read the full resume text from: {resume_path}\n\n" + "Step 2 — Extract the information into EXACTLY this JSON structure. " + "Keep every key. When the resume does not state a value, keep the " + "empty default shown (\"\", null, [], false or 0). Do not add keys.\n\n" + + schema + + "\n\nExtraction rules:\n" + f" - \"id\" MUST be {ctx.resume_id}.\n" + " - Dates use the YYYY-MM-DD format. When the resume gives only a month " + "or year, use the first day of that month/year. A position's end_date is " + "null when it is the applicant's current job.\n" + " - Do NOT invent, guess, or embellish anything that is not in the resume.\n" + " - Every *_url field (linkedin_url, github_url, twitter_url, " + "website_url, kaggle_url) MUST be a complete, valid URL — it must start " + "with 'http://' or 'https://' and contain a domain (e.g. " + "'https://www.linkedin.com/in/username'). Resumes often show only the " + "clickable link text (e.g. 'LinkedIn', 'Portfolio', 'Kaggle', or a " + "person's name) while the real URL is hidden in the hyperlink. If you " + "can only see such link text and NOT the actual URL, leave that field " + "as an empty string \"\". NEVER put link labels, names, or partial " + "fragments in a URL field.\n" + " - For years_of_experience: if the summary/objective explicitly states " + "a number of years of professional experience (e.g. 'over 2 years of " + "experience'), use that number (rounded down to a whole number). " + "Otherwise leave years_of_experience as 0 — it will be computed from the " + "position dates automatically; do NOT estimate it yourself.\n" + " - Derive has_remote_work_experience, remote_work_type, " + "has_management_experience and management_level from the positions and " + "their descriptions.\n" + " - Write a concise 2-4 sentence brief_summary of the applicant.\n" + " - \"skills\" is a dictionary. If the resume groups its skills under " + "category headings (e.g. \"Languages\", \"Frameworks / Libraries\", " + "\"Cloud & Ops\"), use each heading VERBATIM as a key and list that " + "category's skills as an array of strings. Split parenthetical or " + "comma-separated groupings into individual skill strings (e.g. " + "\"Multi Agent Orchestration (Crew AI, Google ADK)\" -> \"Crew AI\", " + "\"Google ADK\"). If the resume lists skills with NO category headings, " + "put them all under a single \"Default\" key. Only include skills that " + "actually appear in the resume.\n" + " Examples:\n" + " Resume text: 'Languages: Python, C++, SQL\\nCloud & Ops: GCP, AWS, " + "Docker' -> \"skills\": {\"Languages\": [\"Python\", \"C++\", \"SQL\"], " + "\"Cloud & Ops\": [\"GCP\", \"AWS\", \"Docker\"]}\n" + " Resume text: 'Programming: Python, C, C++\\nTools & Technologies: " + "PyTorch, Git' -> \"skills\": {\"Programming\": [\"Python\", \"C\", " + "\"C++\"], \"Tools & Technologies\": [\"PyTorch\", \"Git\"]}\n" + " Resume text: 'Python, HTML, CSS, JavaScript, React, AWS' -> " + "\"skills\": {\"Default\": [\"Python\", \"HTML\", \"CSS\", " + "\"JavaScript\", \"React\", \"AWS\"]}\n" + " - The JSON must be strictly valid: no comments, no trailing commas, " + "no markdown fences.\n\n" + "Step 3 — MANDATORY FINAL STEP:\n" + " Call the save_parsed_resume tool with the COMPLETE JSON string. If it " + "returns an ERROR, fix the reported problem and call it again with the " + "corrected JSON. Only after it returns SUCCESS, output that same JSON as " + "your final answer." + ), + expected_output=( + "The complete parsed-resume JSON document, exactly as successfully " + "persisted via the save_parsed_resume tool." + ), + tools=[read_resume, make_save_parsed_resume_tool(ctx)], + agent=resume_analyzer, + async_execution=run_async, + guardrail=make_parsed_resume_guardrail(ctx), + guardrail_max_retries=3, + ) + + def _github_summary_task() -> Task: - """Task 1 — index GitHub repos and summarise the most relevant projects.""" + """GitHub task — index GitHub repos and summarise the most relevant projects.""" return Task( description=( "You have been given structured job details in JSON format:\n\n" @@ -85,12 +189,16 @@ def _github_summary_task() -> Task: ), tools=[extract_github_repos_tool, repo_content_searcher], agent=github_project_summarizer, - async_execution=False, + # Runs in parallel with the resume analysis task; the profile task's + # context drains both before profiling starts. + async_execution=True, ) -def _profile_task(include_github: bool, github_task: Task | None) -> Task: - """Task 2 — build a comprehensive applicant profile.""" +def _profile_task( + include_github: bool, github_task: Task | None, resume_analysis_task: Task +) -> Task: + """Profile task — build a comprehensive applicant profile.""" steps = _PROFILE_GITHUB_STEPS if include_github else _PROFILE_NO_GITHUB_STEPS grounded_in = "the resume, GitHub work, and" if include_github else "the resume and" return Task( @@ -98,12 +206,21 @@ def _profile_task(include_github: bool, github_task: Task | None) -> Task: "Compile a detailed personal and professional profile for the applicant.\n\n" "Job details (for context):\n{job_details_json}\n\n" "Steps:\n" - " 1. Read and semantically understand the resume from: {resume_path}\n" + " 1. Use the structured parsed-resume JSON from the resume analysis task " + "(provided in your context) as the PRIMARY source of the applicant's " + "details: contact info, positions, skills, education, projects, " + "publications, certifications, and experience summary.\n" " 2. Use the job_description and requirements from the job details above " "to understand what the employer is looking for.\n" + " 3. Only if a detail is missing or unclear in the parsed JSON, consult " + "the raw resume at: {resume_path}\n" + steps ), - context=[github_task] if include_github else [], + context=( + [github_task, resume_analysis_task] + if include_github + else [resume_analysis_task] + ), expected_output=( "A comprehensive profile document that includes the applicant's skills, " "project experiences, contributions, interests, and communication style, " @@ -116,7 +233,7 @@ def _profile_task(include_github: bool, github_task: Task | None) -> Task: def _resume_strategy_task(include_github: bool, profile_task: Task) -> Task: - """Task 3 — tailor the resume to the job.""" + """Resume strategy task — tailor the resume to the job.""" clause = _RESUME_GITHUB_CLAUSE if include_github else _RESUME_NO_GITHUB_CLAUSE return Task( description=( @@ -142,7 +259,7 @@ def _resume_strategy_task(include_github: bool, profile_task: Task) -> Task: def _interview_preparation_task(profile_task: Task, resume_task: Task) -> Task: - """Task 4 — generate interview questions and talking points.""" + """Interview task — generate interview questions and talking points.""" return Task( description=( "Create targeted interview questions and talking points for the applicant.\n\n" @@ -165,24 +282,44 @@ def _interview_preparation_task(profile_task: Task, resume_task: Task) -> Task: ) -def build_tasks(include_github: bool = True) -> list[Task]: +def build_tasks( + include_github: bool = True, + resume_ctx: ResumeAnalysisContext | None = None, +) -> list[Task]: """ Construct a fresh, correctly-wired task list for one crew run. + Task order: [github?, resume_analysis, profile, resume, interview]. The + GitHub summary and resume analysis tasks run with ``async_execution=True`` + (in parallel); the profile task lists them in its ``context`` and starts + only after both succeed. If the resume analysis fails (guardrail retries + exhausted), ``kickoff()`` raises before the profile task runs. + When ``include_github`` is False the GitHub summarizer task is omitted and the profile/resume tasks are given grounding rules so the agents do not fabricate GitHub-derived content. + ``resume_ctx`` carries the user/resume identity for the resume analysis + task's save tool. When None (standalone CLI), a local-only context is used: + the parsed JSON is written to temp storage but never uploaded to GCS or + recorded in the database. + Return order is stable across both modes: the interview task is always the last element and the resume task always the second-to-last, so the runner can stamp per-request ``output_file`` paths via ``crew.tasks[-1]/[-2]``. """ + if resume_ctx is None: + resume_ctx = ResumeAnalysisContext( + user_id=0, resume_id=0, user_name="cli_user", local_only=True + ) + github_task = _github_summary_task() if include_github else None - profile = _profile_task(include_github, github_task) + analysis = _resume_analysis_task(resume_ctx) + profile = _profile_task(include_github, github_task, analysis) resume = _resume_strategy_task(include_github, profile) interview = _interview_preparation_task(profile, resume) - tasks = [profile, resume, interview] + tasks = [analysis, profile, resume, interview] if include_github: tasks.insert(0, github_task) return tasks diff --git a/backend/app/services/crew_runner.py b/backend/app/services/crew_runner.py index 335029c..7e1427b 100644 --- a/backend/app/services/crew_runner.py +++ b/backend/app/services/crew_runner.py @@ -45,12 +45,17 @@ def _update_progress(job_id: int, step: str, error: str = None): def _run_crew_sync( job_id: int, user_id: int, user_email: str, github_username: str = None, github_url: str = None, bq_dataset_name: str = None, - resume_content: str = None, job_url: str = None, + resume_bytes: bytes = None, resume_filename: str = None, job_url: str = None, + resume_id: int = None, user_name: str = None, ): """ Synchronous crew execution — runs in a thread. This imports and uses the existing crew pipeline. + The raw resume file bytes (.pdf/.docx/.md) are parsed to text BEFORE the + crew is built; a ResumeParsingError aborts the run so the crew never + starts and the job is marked failed (the UI offers a retry). + When no GitHub URL is supplied the crew is built without the GitHub summarizer agent/task and tailors the resume from the resume + job details alone. @@ -72,20 +77,42 @@ def _run_crew_sync( logger.error("crew_runner: Failed to extract job details for job %d: %s", job_id, e) raise RuntimeError(f"Failed to extract job details: {e}") + # Step 2: Parse the resume document (.pdf/.docx/.md) into text. + # Failure here must stop everything — no crew, no LLM tokens. + _update_progress(job_id, "parsing_resume") + from app.services.resume_parser import ResumeParsingError, extract_resume_text + + suffix = Path(resume_filename).suffix.lower() if resume_filename else ".md" + with tempfile.NamedTemporaryFile(mode="wb", suffix=suffix, delete=False, prefix="resume_raw_") as f: + f.write(resume_bytes) + raw_resume_path = f.name + + try: + resume_text = extract_resume_text(raw_resume_path) + except ResumeParsingError as e: + logger.error( + "crew_runner: Resume parsing failed for job %d (resume %s, file '%s'): %s", + job_id, resume_id, resume_filename, e, exc_info=True, + ) + if os.path.exists(raw_resume_path): + os.unlink(raw_resume_path) + raise RuntimeError(f"Resume parsing failed: {e}") - # Step 2: Write resume to temp file for crew to read + # Step 3: Write extracted text to a temp .md for the crew to read + # (FileReadTool / MDXSearchTool operate on this file). # The "searching_projects" step only applies when GitHub repos are indexed; # without GitHub we advance straight to building the profile. _update_progress(job_id, "searching_projects" if include_github else "building_profile") with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, prefix="resume_") as f: - f.write(resume_content) + f.write(resume_text) resume_path = f.name try: - # Step 3: Build crew and run + # Step 4: Build crew and run _update_progress(job_id, "building_profile") from app.services.crew.crew import build_crew + from app.services.crew.resume_tools import ResumeAnalysisContext applicant_name = user_email.split("@")[0].replace(".", "_") @@ -97,9 +124,20 @@ def _run_crew_sync( ts = datetime.now().strftime("%Y%m%d_%H%M%S") crew_log_path = str(_LOG_DIR / f"{ts}_crew") + # Identity context for the resume_analyzer's save tool: the parsed + # JSON is uploaded to GCS and recorded on resumes.parsed_resume_path. + # resume_text is passed as the hallucination-guardrail reference context. + resume_ctx = ResumeAnalysisContext( + user_id=user_id, + resume_id=resume_id, + user_name=user_name or applicant_name, + local_only=False, + resume_text=resume_text, + ) + crew = build_crew( output_log_file=crew_log_path, verbose=True, tracing=False, - include_github=include_github, + include_github=include_github, resume_ctx=resume_ctx, ) logger.info("crew_runner: Crew verbose log → %s", crew_log_path) @@ -124,6 +162,11 @@ def _run_crew_sync( result = crew.kickoff(inputs=job_application_inputs) logger.info("crew_runner: Crew execution completed for job %d. Log saved to %s", job_id, crew_log_path) + if resume_ctx.state.get("gcs_path"): + logger.info( + "crew_runner: Parsed resume for job %d stored at %s", + job_id, resume_ctx.state["gcs_path"], + ) # Read output files tailored_resume_content = "" @@ -147,7 +190,7 @@ def _run_crew_sync( finally: # Clean up temp files - for path in [resume_path]: + for path in [resume_path, raw_resume_path]: if os.path.exists(path): os.unlink(path) @@ -156,6 +199,7 @@ def _run_crew_sync( async def run_crew_for_job( job_id: int, user_id: int, user_email: str, github_profile, resume_gcs_path: str, job_url: str, + resume_id: int = None, user_name: str = None, resume_filename: str = None, ): """ Async entry point for crew execution. Updates job status in DB. @@ -163,13 +207,17 @@ async def run_crew_for_job( ``github_profile`` may be ``None`` when the user tailors a resume without a GitHub profile; the crew then runs without the GitHub summarizer. + + ``resume_id`` / ``user_name`` / ``resume_filename`` identify the resume for + the resume_analyzer agent (parsed-resume filename, GCS blob, and the + resumes.parsed_resume_path DB update). The raw bytes are kept binary — + PDF/DOCX resumes must not be utf-8 decoded. """ _update_progress(job_id, "pending") try: - # Download resume from GCS + # Download resume from GCS (raw bytes — may be a binary .pdf/.docx) resume_bytes = download_file(resume_gcs_path) - resume_content = resume_bytes.decode("utf-8") # Update status to processing async with AsyncSessionLocal() as db: @@ -190,7 +238,8 @@ async def run_crew_for_job( _executor, _run_crew_sync, job_id, user_id, user_email, gh_username, gh_url, gh_dataset, - resume_content, job_url, + resume_bytes, resume_filename, job_url, + resume_id, user_name, ) # Save extracted job details to DB on the correct async event loop diff --git a/backend/app/services/gcs_service.py b/backend/app/services/gcs_service.py index 86130e4..e4d7718 100644 --- a/backend/app/services/gcs_service.py +++ b/backend/app/services/gcs_service.py @@ -5,18 +5,27 @@ signed URLs for resume and interview material files. Bucket structure: - gs:///uploads//.md — User-uploaded original resumes + gs:///uploads// — User-uploaded original resumes (.md/.pdf/.docx) + gs:///parsed//___parsed_resume.json — Agent-parsed resume JSON gs:///tailored//_resume.md — Agent-generated tailored resumes gs:///tailored//_interview.md — Agent-generated interview materials """ import datetime +from pathlib import Path + from google.cloud import storage from app.core.config import get_settings settings = get_settings() +_RESUME_CONTENT_TYPES = { + ".md": "text/markdown", + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +} + def _get_client() -> storage.Client: """Return a GCS client.""" @@ -52,14 +61,41 @@ def upload_resume(user_id: int, file_bytes: bytes, filename: str) -> str: ) blob_path = f"uploads/{user_id}/{filename}" + content_type = _RESUME_CONTENT_TYPES.get( + Path(filename).suffix.lower(), "application/octet-stream" + ) bucket = _get_bucket() blob = bucket.blob(blob_path) - blob.upload_from_string(file_bytes, content_type="text/markdown") + blob.upload_from_string(file_bytes, content_type=content_type) gcs_path = f"gs://{settings.GCS_BUCKET_NAME}/{blob_path}" return gcs_path +def upload_parsed_resume(user_id: int, filename: str, content: str) -> str: + """ + Upload the resume_analyzer agent's parsed-resume JSON to GCS. + + The blob path is deterministic (parsed//), so a retried + job simply overwrites the previous upload. + + Args: + user_id: The user's database ID. + filename: Parsed-resume filename + (___parsed_resume.json). + content: JSON content of the parsed resume. + + Returns: + The GCS path (gs://bucket/parsed//). + """ + blob_path = f"parsed/{user_id}/{filename}" + bucket = _get_bucket() + blob = bucket.blob(blob_path) + blob.upload_from_string(content.encode("utf-8"), content_type="application/json") + + return f"gs://{settings.GCS_BUCKET_NAME}/{blob_path}" + + def upload_tailored_resume(user_id: int, job_id: int, content: str, filename: str) -> str: """ Upload an agent-generated tailored resume to GCS. diff --git a/backend/app/services/resume_parser.py b/backend/app/services/resume_parser.py new file mode 100644 index 0000000..5d663d4 --- /dev/null +++ b/backend/app/services/resume_parser.py @@ -0,0 +1,454 @@ +""" +app.services.resume_parser +-------------------------- +Document-type detection and text extraction for user-uploaded resumes +(.pdf / .docx / .md), plus the Pydantic models and JSON schema template +used by the resume_analyzer agent. + +Extraction strategy: + .pdf — pypdf first; if it errors or yields near-empty text (e.g. a + scanned PDF), fall back to pdfplumber; if that also fails, + raise ResumeParsingError. + .docx — python-docx (paragraphs + table cells); failures raise + ResumeParsingError. + .md — file content used as-is. +""" + +from pathlib import Path +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + +from app.core.logging import get_logger + +logger = get_logger(__name__) + +# A pypdf result with fewer non-whitespace characters than this is treated as +# a failed extraction (typically a scanned/image-only PDF) and retried with +# pdfplumber. +_MIN_EXTRACTED_CHARS = 50 + +SUPPORTED_EXTENSIONS = {".pdf": "pdf", ".docx": "docx", ".md": "md"} + + +class ResumeParsingError(Exception): + """Raised when a resume file cannot be parsed into text.""" + + +# ── Document type detection ─────────────────────────────────────────────────── + +def detect_document_type(file_path: str) -> str: + """ + Return the document type ("pdf" | "docx" | "md") for ``file_path``. + + Raises: + ResumeParsingError: If the extension is not one of .pdf/.docx/.md. + """ + suffix = Path(file_path).suffix.lower() + doc_type = SUPPORTED_EXTENSIONS.get(suffix) + if doc_type is None: + raise ResumeParsingError( + f"Unsupported resume document type '{suffix}' for file '{file_path}'. " + f"Supported types: {', '.join(sorted(SUPPORTED_EXTENSIONS))}." + ) + return doc_type + + +# ── Per-type extractors ─────────────────────────────────────────────────────── + +def _non_whitespace_len(text: str) -> int: + return len("".join(text.split())) + + +def extract_pdf_text(file_path: str) -> str: + """ + Extract text from a PDF resume. + + Tries pypdf first; on any exception or a near-empty result falls back to + pdfplumber. If both fail, raises ResumeParsingError. + """ + pypdf_error: Exception | None = None + try: + from pypdf import PdfReader + + reader = PdfReader(file_path) + text = "\n".join((page.extract_text() or "") for page in reader.pages) + if _non_whitespace_len(text) >= _MIN_EXTRACTED_CHARS: + logger.info( + "resume_parser: pypdf extracted %d chars from '%s' (%d pages)", + len(text), file_path, len(reader.pages), + ) + return text + logger.warning( + "resume_parser: pypdf returned near-empty text (%d non-ws chars) for '%s'; " + "falling back to pdfplumber", + _non_whitespace_len(text), file_path, + ) + except Exception as e: # noqa: BLE001 — any pypdf failure triggers the fallback + pypdf_error = e + logger.warning( + "resume_parser: pypdf failed for '%s' (%s); falling back to pdfplumber", + file_path, e, + ) + + try: + import pdfplumber + + with pdfplumber.open(file_path) as pdf: + text = "\n".join((page.extract_text() or "") for page in pdf.pages) + if _non_whitespace_len(text) < _MIN_EXTRACTED_CHARS: + raise ResumeParsingError( + f"pdfplumber extracted no meaningful text from '{file_path}' " + "(possibly a scanned/image-only PDF)." + ) + logger.info( + "resume_parser: pdfplumber extracted %d chars from '%s'", len(text), file_path + ) + return text + except ResumeParsingError: + logger.error( + "resume_parser: PDF extraction failed for '%s' — pypdf%s and pdfplumber " + "both produced no usable text", + file_path, f" ({pypdf_error})" if pypdf_error else "", + exc_info=True, + ) + raise + except Exception as e: + logger.error( + "resume_parser: PDF extraction failed for '%s' — pypdf error: %s; " + "pdfplumber error: %s", + file_path, pypdf_error, e, exc_info=True, + ) + raise ResumeParsingError( + f"Failed to parse PDF '{file_path}': pypdf error: {pypdf_error}; " + f"pdfplumber error: {e}" + ) from e + + +def extract_docx_text(file_path: str) -> str: + """ + Extract text from a DOCX resume (paragraphs and table cells). + + Raises: + ResumeParsingError: If python-docx cannot read the file or the file + contains no meaningful text. + """ + try: + from docx import Document + + document = Document(file_path) + parts = [para.text for para in document.paragraphs] + for table in document.tables: + for row in table.rows: + parts.extend(cell.text for cell in row.cells) + text = "\n".join(part for part in parts if part and part.strip()) + except Exception as e: + logger.error( + "resume_parser: python-docx failed for '%s': %s", file_path, e, exc_info=True + ) + raise ResumeParsingError(f"Failed to parse DOCX '{file_path}': {e}") from e + + if _non_whitespace_len(text) < _MIN_EXTRACTED_CHARS: + logger.error( + "resume_parser: DOCX '%s' contains no meaningful text (%d non-ws chars)", + file_path, _non_whitespace_len(text), + ) + raise ResumeParsingError( + f"DOCX '{file_path}' contains no meaningful text." + ) + logger.info( + "resume_parser: python-docx extracted %d chars from '%s'", len(text), file_path + ) + return text + + +def extract_md_text(file_path: str) -> str: + """Return a Markdown resume's content as-is.""" + try: + text = Path(file_path).read_text(encoding="utf-8") + except UnicodeDecodeError: + logger.warning( + "resume_parser: '%s' is not valid utf-8; re-reading with errors='replace'", + file_path, + ) + text = Path(file_path).read_text(encoding="utf-8", errors="replace") + except Exception as e: + logger.error( + "resume_parser: failed to read markdown '%s': %s", file_path, e, exc_info=True + ) + raise ResumeParsingError(f"Failed to read Markdown '{file_path}': {e}") from e + logger.info("resume_parser: read %d chars from markdown '%s'", len(text), file_path) + return text + + +def extract_resume_text(file_path: str) -> str: + """ + Detect the document type of ``file_path`` and extract its text. + + Raises: + ResumeParsingError: If the type is unsupported or extraction fails. + """ + doc_type = detect_document_type(file_path) + logger.info("resume_parser: extracting '%s' as %s", file_path, doc_type) + if doc_type == "pdf": + return extract_pdf_text(file_path) + if doc_type == "docx": + return extract_docx_text(file_path) + return extract_md_text(file_path) + + +# ── Parsed-resume filename helpers ──────────────────────────────────────────── + +def sanitize_user_name(name: str) -> str: + """Lowercase ``name`` and collapse every non-alphanumeric run to a single '_'.""" + sanitized = "".join(c if c.isalnum() else "_" for c in name.lower()) + while "__" in sanitized: + sanitized = sanitized.replace("__", "_") + return sanitized.strip("_") or "user" + + +def build_parsed_resume_filename(user_name: str, user_id: int, resume_id: int) -> str: + """Return '___parsed_resume.json'.""" + return f"{sanitize_user_name(user_name)}_{user_id}_{resume_id}_parsed_resume.json" + + +# ── Structured output models (guardrail validation) ────────────────────────── + +class ProjectItem(BaseModel): + project_name: str = "" + description: str = "" + url: str = "" + + +class PublicationItem(BaseModel): + title: str = "" + publisher: str = "" + date: str = "" + url: str = "" + + +class PositionItem(BaseModel): + position_name: str = "" + company_name: str = "" + country: str = "" + start_date: str = "" + end_date: Optional[str] = None + skills: List[str] = Field(default_factory=list) + job_type: str = "" + job_details: str = "" + + +class EducationItem(BaseModel): + school_name: str = "" + school_type: str = "" + degree_type: str = "" + faculty_department: str = "" + specialization_subjects: str = "" + country: str = "" + start_date: str = "" + end_date: str = "" + learning_mode: str = "" + education_details: str = "" + + +class ParsedResumeResult(BaseModel): + name: str = "" + email: str = "" + phone: str = "" + address: str = "" + city: str = "" + country: str = "" + language: str = "" + spoken_languages: List[str] = Field(default_factory=list) + honors_and_awards: List[str] = Field(default_factory=list) + courses_and_certifications: List[str] = Field(default_factory=list) + linkedin_url: str = "" + github_url: str = "" + twitter_url: str = "" + website_url: str = "" + kaggle_url: str = "" + date_of_birth: Optional[str] = None + nationality: str = "" + summary_objective: str = "" + work_authorization: str = "" + years_of_experience: int = 0 + has_remote_work_experience: bool = False + remote_work_type: str = "" + has_management_experience: bool = False + management_level: str = "" + approximate_age: Optional[int] = None + brief_summary: str = "" + drivers_licenses: List[str] = Field(default_factory=list) + interests_hobbies: List[str] = Field(default_factory=list) + projects: List[ProjectItem] = Field(default_factory=list) + volunteer_experience: List[str] = Field(default_factory=list) + publications: List[PublicationItem] = Field(default_factory=list) + positions: List[PositionItem] = Field(default_factory=list) + # Skills grouped by the category headings used in the resume (e.g. + # "Languages", "Frameworks / Libraries"). When the resume lists skills + # without any category headings, all skills go under a single "Default" key. + skills: Dict[str, List[str]] = Field(default_factory=dict) + education_qualifications: List[EducationItem] = Field(default_factory=list) + + +class ParsedResume(BaseModel): + id: int + result: ParsedResumeResult + + +# ── URL validation & years-of-experience computation ───────────────────────── + +# The *_url fields that must hold a valid URL or an empty string. +URL_FIELDS = ( + "linkedin_url", "github_url", "twitter_url", "website_url", "kaggle_url", +) + + +def is_valid_url(value: str) -> bool: + """ + Return True if ``value`` is a syntactically valid http(s) URL. + + An empty string is considered valid (a URL that could not be extracted is + intentionally left blank). Bare link labels ("LinkedIn", "Portfolio", + "Kaggle", a person's name) are rejected. + """ + if value == "": + return True + from urllib.parse import urlparse + + try: + parsed = urlparse(value.strip()) + except Exception: # noqa: BLE001 — any parse failure is an invalid URL + return False + return parsed.scheme in ("http", "https") and bool(parsed.netloc) and "." in parsed.netloc + + +def find_invalid_url_fields(result: "ParsedResumeResult") -> List[str]: + """Return the names of *_url fields on ``result`` that are not valid URLs.""" + return [field for field in URL_FIELDS if not is_valid_url(getattr(result, field, ""))] + + +def _parse_iso_date(value: Optional[str]): + """Parse a YYYY-MM-DD string into a ``date``; return None on any failure.""" + if not value: + return None + from datetime import date + + try: + return date.fromisoformat(value.strip()[:10]) + except (ValueError, TypeError): + return None + + +def compute_years_of_experience(result: "ParsedResumeResult") -> int: + """ + Compute total years of professional experience from the position dates. + + The span runs from the earliest position start_date to the latest end_date + (a missing/blank end_date is treated as today, i.e. a current position), + and is floored to whole years. Returns 0 when no usable dates exist. + """ + from datetime import date + + starts, ends = [], [] + for pos in result.positions: + start = _parse_iso_date(pos.start_date) + if start is None: + continue + starts.append(start) + # A blank/missing end_date means the position is current → use today. + end = _parse_iso_date(pos.end_date) or date.today() + ends.append(end) + + if not starts: + return 0 + + earliest, latest = min(starts), max(ends) + if latest <= earliest: + return 0 + return (latest - earliest).days // 365 + + +# JSON template embedded verbatim in the resume-analysis task description. +# Safe for CrewAI description interpolation: interpolation only replaces bare +# {identifier} tokens, and every brace here is followed by a quoted key or +# whitespace. Never add a bare {word} token to this literal — CrewAI would +# raise a KeyError for a missing crew input. +PARSED_RESUME_SCHEMA: str = """{ + "id": , + "result": { + "name": "", + "email": "", + "phone": "", + "address": "", + "city": "", + "country": "", + "language": "", + "spoken_languages": [], + "honors_and_awards": [], + "courses_and_certifications": [], + "linkedin_url": "", + "github_url": "", + "twitter_url": "", + "kaggle_url": "", + "website_url": "", + "date_of_birth": null, + "nationality": "", + "summary_objective": "", + "work_authorization": "", + "years_of_experience": 0, + "has_remote_work_experience": false, + "remote_work_type": "", + "has_management_experience": false, + "management_level": "", + "approximate_age": null, + "brief_summary": "", + "drivers_licenses": [], + "interests_hobbies": [], + "projects": [ + { + "project_name": "", + "description": "", + "url": "" + } + ], + "volunteer_experience": [], + "publications": [ + { + "title": "", + "publisher": "", + "date": "", + "url": "" + } + ], + "positions": [ + { + "position_name": "", + "company_name": "", + "country": "", + "start_date": "", + "end_date": null, + "skills": [], + "job_type": "", + "job_details": "" + } + ], + "skills": { + "Default": [] + }, + "education_qualifications": [ + { + "school_name": "", + "school_type": "", + "degree_type": "", + "faculty_department": "", + "specialization_subjects": "", + "country": "", + "start_date": "", + "end_date": "", + "learning_mode": "", + "education_details": "" + } + ] + } +}""" diff --git a/backend/docs/resume_analyzer_agent_creation_prompt.txt b/backend/docs/resume_analyzer_agent_creation_prompt.txt new file mode 100644 index 0000000..54930f1 --- /dev/null +++ b/backend/docs/resume_analyzer_agent_creation_prompt.txt @@ -0,0 +1,370 @@ +Create a new CrewAI agent called "resume_analyzer" that carefully scans and analyzes the given resume to extract all the relevant information it can find and produce them as output in the JSON format given below. It should create a file called "___parsed_resume.json" in local temp storage and then upload it to the GCS storage. It should store the GCS path of the uploaded file in the database table "Resume" under field "parsed_resume_path". Create this field if the current Database table does not have it. Modify "backend/app/models/__init__.py" which contains the Database table details accordingly. Also update the schema if necessary present in "backend/app/schemas/__init__.py". The agent should only write to the database if the file creation and the subsequent upload of the file to GCS is successful. + +Use existing tools or create new tools that this agent can use to write, upload files or update database. + +Create helper functions to first check the document type (.pdf, .docx or .md), then create three helper functions to extract data from each document type. +For PDF use "PyPDF" package initially and if it fails to properly parse the document, use "pdfplumber" package. If that even fails, raise proper Error. +For .docx type, use "python-docx" package to extract the data and implemenent proper failure resolution. +For .md file use the file as is. + +If the parsing fails, the Crew should not continue further and notify the user that resume parsing failed and all related logs should be carefully written. +The user will have the option to try again from the UI interface which is already implemented. Carefully think and create the relevant CrewAI task for the same. +The Resume analyzing task should be done in parallel to the "github_task". Modify crew in "backend/app/services/crew/crew.py" and build_tasks in "backend/app/services/crew/tasks.py" accordingly. +Always follow the official documentation guides while implementing CrewAI related code. You can use Context7 MCP to get latest documentation. + +The contents of this JSON file should be passed to the "profiler" agent. So also modify the task description of "_profile_task" and the task build order in "backend/app/services/crew/tasks.py". + +Create a python script in "backend/tests" to run this agent and task in isolation to check the output quality. +Let the agent create the output file in local temp storage but it should not upload to gcs or write to database. +It should copy the file from temp folder to an "output" folder inside "backend/tests". +The filename should be "test_parsed_resume_.json" as the usual filename mentioned above cannot be used due to lack of user_name, user_id and resume_id information. + +Output JSON format: +""" +{ "id": + "result": { + "name": "", + "email": "", + "phone": "", + "address": "", + "city": "", + "country": "", + "language": "", + "spoken_languages": [], + "honors_and_awards": [], + "courses_and_certifications": [], + "linkedin_url": "", + "github_url": "", + "twitter_url": "", + "website_url": "", + "date_of_birth": null, + "nationality": "", + "summary_objective": "", + "work_authorization": "", + "years_of_experience": 0, + "has_remote_work_experience": false, + "remote_work_type": "", + "has_management_experience": false, + "management_level": "", + "approximate_age": null, + "brief_summary": "", + "drivers_licenses": [], + "interests_hobbies": [], + "projects": [ + { + "project_name": "", + "description": "", + "url": "" + } + ], + "volunteer_experience": [], + "publications": [ + { + "title": "", + "publisher": "", + "date": "", + "url": "" + } + ], + "positions": [ + { + "position_name": "", + "company_name": "", + "country": "", + "start_date": "", + "end_date": null, + "skills": [], + "job_type": "", + "job_details": "" + } + ], + "education_qualifications": [ + { + "school_name": "", + "school_type": "", + "degree_type": "", + "faculty_department": "", + "specialization_subjects": "", + "country": "", + "start_date": "", + "end_date": "", + "learning_mode": "", + "education_details": "" + } + ] + } +} +""" + +Example Output: +Source file present in "/home/arijit/Job_Application_Agent/backend/sample_data/Arijit De Resume 2026.pdf" +""" +{ + "id": 2 + "result": { + "name": "Arijit De", + "email": "arijitde2050@gmail.com", + "phone": "+918981800329", + "address": "", + "city": "Kolkata", + "country": "India", + "language": "", + "spoken_languages": [], + "honors_and_awards": [], + "courses_and_certifications": [ + "Deep Learning, a 5-course specialization, by Deeplearning.ai. Verify here.", + "MCPS: Microsoft Certified Professional, Microsoft Certification No.: 1042C4-5DHF30" + ], + "linkedin": "https://www.linkedin.com/in/de-arijit/", + "github": "https://github.com/arijitde92", + "twitter": "", + "website": "", + "date_of_birth": null, + "nationality": "", + "summary_objective": "", + "work_authorization": "", + "years_of_experience": 8, + "has_remote_work_experience": true, + "remote_work_type": "fully_remote", + "has_management_experience": true, + "management_level": "team_lead", + "approximate_age": null, + "brief_summary": "Machine Learning Engineer and Computer Vision Engineer with experience in multi-agent AI systems, RAG pipelines, MLOps, backend engineering, and 3D computer vision. Strong background in Python, PyTorch, FastAPI, cloud platforms, and applied AI research, with industry and academic experience across construction tech, neuroimaging, ADAS, and enterprise systems.", + "drivers_licenses": [], + "interests_hobbies": [], + "projects": [ + { + "project_name": "Doctor Appointment Scheduler", + "description": "Architected and deployed a scalable, production-level Multi-Agent System for appointment scheduling. Designed the Agent Orchestration and multi agent delegation and coordination using Google ADK, integrating intelligent doctor matching and calendar scheduling (task hand-off/execution). Implemented robust Tool Calling and Execution using Composio for calendar actions and Google Maps MCP. The scalable Backend Engineering architecture utilizes Google Cloud Run for containerized deployment, integrating Google Cloud SQL and Gemini Enterprise.", + "url": "https://doctor-appointment-app-173427564927.asia-south1.run.app/" + }, + { + "project_name": "Job Application Agent", + "description": "Developed a real-world Multi-Agent System for automation, utilizing Crew AI for advanced workflow orchestration of specialized agents. Engineered Shared State/Memory by integrating Google BigQuery (vector store) and Vertex AI (embeddings) for consistent, scalable semantic search and storage across agents. Implemented custom tools (LinkedIn/GitHub scrapers) and leveraged LangChain for core system logic, all built in Python.", + "url": "" + } + ], + "volunteer_experience": [], + "publications": [ + { + "title": "Predicting Genetic Markers for Brain Tumors Using a Composite Loss", + "publisher": "IEEE Transactions on Computational Biology and Bioinformatics (TCBB)", + "date": "2025-07-01", + "url": "" + }, + { + "title": "Shape Induced Multi-class Deep Graph Cut for Hippocampus Subfield Segmentation", + "publisher": "Springer, Cham", + "date": "2025-01-01", + "url": "https://doi.org/10.1007/978-3-031-78201-5_16" + }, + { + "title": "3D Hippocampus Segmentation Using a Hog Based Loss Function with Majority Pooling", + "publisher": "2023 IEEE International Conference on Image Processing (ICIP)", + "date": "2023-10-01", + "url": "" + }, + { + "title": "Brain Tumor Classification from Radiology and Histopathology using Deep Features and Graph Convolutional Network", + "publisher": "2022 26th International Conference on Pattern Recognition (ICPR 2022)", + "date": "2022-01-01", + "url": "" + }, + { + "title": "A Deep Graph Cut Model for 3D Brain Tumor Segmentation", + "publisher": "44th Annual International Conference of the IEEE Engineering in Medicine & Biology Society (EMBC 2022)", + "date": "2022-01-01", + "url": "" + }, + { + "title": "DTI based Alzheimer's Disease Classification with Rank Modulated Fusion of CNNs and Random Forest", + "publisher": "Expert Syst. Appl.", + "date": "2021-01-01", + "url": "" + } + ], + "references": [], + "positions": [ + { + "position_name": "Computer Vision Engineer", + "company_name": "Synerjix", + "country": "USA", + "start_date": "2025-06-01", + "end_date": "2025-12-01", + "skills": [ + "DinoV3", + "Python", + "FastAPI", + "MySQL", + "AWS", + "Sagemaker", + "EC2", + "EKS", + "CI/CD", + "Docker" + ], + "job_type": "Remote", + "job_details": "Smart Moving Assistant Created a Smart Moving Assistant, helping users estimate shifting cost of items using room photos. Used DinoV3 for object detection, python and FastAPI for backend development, MySQL as the Database and AWS (Sagemaker, EC2, EKS) as the deployment platform along with CI/CD and Docker. Automation improved moving estimation by 70% and improved stakeholder business efficiency by 50%." + }, + { + "position_name": "Machine Learning Engineer", + "company_name": "mVizn Pte. Ltd.", + "country": "Singapore", + "start_date": "2024-01-01", + "end_date": null, + "skills": [ + "CrewAI", + "Retrieval-Augmented Generation (RAG)", + "Pinecone", + "LoRA", + "Gemini", + "Python", + "PyTorch", + "MLflow", + "FastAPI", + "Pydantic", + "asyncio", + "ONNX", + "Docker", + "Google Cloud Platform (GCP)" + ], + "job_type": "Remote", + "job_details": "Smart Property Manager Project Singapore (Remote) Developed a multi-agent platform for the construction domain using CrewAI enabling Autonomous Project Management & Scheduling (APMS), Automated Estimation & Procurement (AEP), and intelligent project information retrieval increasing overall business efficiency by 50%. Conceptualized the orchestration layer for governing through intelligent state & memory management, context propagation, and dynamic routing of user intent. Designed robust multi-agent workflows by balancing technical trade-offs and ensuring model observability, tracing and performance monitoring. Built multi-modal Retrieval-Augmented Generation (RAG) pipelines optimizing for performance and accurate retrieval with hybrid search and reranking techniques across massive datasets of text, images and CAD files leveraging multiple types of embeddings stored in Pinecone. Applied LoRA fine-tuning to adapt Gemini LLMs for domain understanding & structured output formatting. 3D Scan-to-BIM Project Led the development of a scalable end-to-end machine learning pipeline from training to deployment for 3D semantic segmentation of indoor point clouds extracted from LiDAR scans using Python and PyTorch reducing Building Information Modeling (BIM) creation time by 60%. Implemented MLflow-based experiment tracking, model versioning, model registry, and reproducible training workflows to support scalable MLOps practices. Designed and implemented scalable, robust backend architectures using FastAPI, Pydantic, asyncio and multi threaded programming. Optimized models through ONNX and deployed services using Docker and Google Cloud Platform (GCP), including Cloud Storage, Compute Engine VMs, IAM and Cloud SQL services." + }, + { + "position_name": "Computer Vision Engineer", + "company_name": "Institute of Neurosciences", + "country": "India", + "start_date": "2022-01-01", + "end_date": "2023-12-01", + "skills": [ + "PyTorch", + "Python", + "OpenCV", + "Numpy", + "ETL", + "Matplotlib", + "Streamlit", + "Docker" + ], + "job_type": "Onsite", + "job_details": "AI Assisted Neuroimaging Kolkata, India Designed and implemented custom 3D Computer Vision solutions related to various Neurological disorders using PyTorch, Python, OpenCV and Numpy improving diagnosis speed by 40%. Created custom data processing and ingestion (ETL) pipelines for handling massive MRI, CT images. Converted raw patient data into valuable information with innovative data analysis and interactive dashboards for stakeholders using Matplotlib, Streamlit, Docker." + }, + { + "position_name": "Machine Learning Engineer", + "company_name": "Mercedes-Benz Research and Development India", + "country": "India", + "start_date": "2018-08-01", + "end_date": "2019-08-01", + "skills": [ + "Computer Vision", + "YOLOv3", + "AWS", + "Python", + "QA" + ], + "job_type": "Onsite", + "job_details": "Advanced driver-assistance system (ADAS) Bengaluru, India Developed and optimized a computer vision pipeline for Vulnerable Road User (VRU) detection using YOLOv3 and large-scale annotated datasets. Designed & deployed model training, evaluation, and validation workflows in AWS. Built automated quality assurance (QA) and evaluation frameworks using Python, improving annotation throughput by 20% and data quality by 25%." + }, + { + "position_name": "Systems Engineer", + "company_name": "Tata Consultancy Services", + "country": "India", + "start_date": "2014-08-01", + "end_date": "2015-08-01", + "skills": [ + "Java", + "MySQL" + ], + "job_type": "Onsite", + "job_details": "Ultimatix Team Kolkata, India Maintained and enhanced high-availability backend systems (Java, MySQL) for TCS’s internal website, ensuring seamless functionality for over 100,000 users. Streamlined feature release cycle, reducing deployment time by 20%." + } + ], + "education_qualifications": [ + { + "school_name": "Jadavpur University", + "school_type": "University or equivalent", + "degree_type": "Doctorate/PhD or equivalent", + "faculty_department": "", + "specialization_subjects": "", + "country": "India", + "start_date": "2019-01-01", + "end_date": "2024-01-01", + "learning_mode": "In-person learning", + "education_details": "PhD, Jadavpur University, Kolkata, India." + }, + { + "school_name": "Jadavpur University", + "school_type": "University or equivalent", + "degree_type": "Master's Degree or equivalent", + "faculty_department": "", + "specialization_subjects": "Computer Science & Engineering", + "country": "India", + "start_date": "2016-01-01", + "end_date": "2018-01-01", + "learning_mode": "In-person learning", + "education_details": "M.Tech. Computer Science & Engineering, Jadavpur University, Kolkata, India. GPA - 8.83" + }, + { + "school_name": "Techno India", + "school_type": "College or equivalent", + "degree_type": "Bachelor's Degree or equivalent", + "faculty_department": "", + "specialization_subjects": "Computer Science & Engineering", + "country": "India", + "start_date": "2010-01-01", + "end_date": "2014-01-01", + "learning_mode": "In-person learning", + "education_details": "B.Tech. Computer Science & Engineering, Techno India, Kolkata, India. GPA - 8.81" + } + ] + } +} +""" + +Modify "PARSED_RESUME_SCHEMA" to add a skills key after "positions". It should be like a dictionary with skills category as the keys based on the categories given in the resume. If there are no categories, then it should have a "default" category. The skills should a list of strings for each category. +Below are three examples - + +1. Extracted text: +""" +Languages: Python, C++, SQL +LLM/Agent Systems: Multi Agent Orchestration (Crew AI, Google ADK, LangGraph), Agentic Memory, + LangChain, LLM, APIs (OpenAI, Gemini, Claude), DeepEval +Frameworks / Libraries: FastAPI, Flask, PyTorch, Scikit-learn, OpenCV, Open3D, PyTest, +Cloud & Ops: Google Cloud Platform (GCP), Amazon Web Services (AWS), Git, Docker, + MLFlow, Github Actions +""" + +Desired skill section: +"skills":{ + "Languages": ["Python", "C++", "SQL"], + "LLM/Agent Systems": ["Crew AI", "Google ADK", "LangGraph", "Agentic Memory", "LangChain", "LLM", "OpenAI", "Gemini", "Claude", "DeepEval"], + "Frameworks / Libraries": ["FastAPI", "Flask", "Pytorch", "Scikit-learn", "OpenCV", "Open3D", "PyTest"], + "Cloud & Ops": ["Google Cloud Platform (GCP)", "Amazon Web Services (AWS)", "Git", "Docker", "MLFlow", "Github Actions"] +} + +2. Extracted Text: +""" +Programming: Python, C, C++, SQL, Core Java +Tools & Technologies: TensorFlow, PyTorch, Scikit-learn, NumPy, Pandas, Matplotlib, Seaborn, NLTK, spaCy, Transformers, Git, Docker, Linux, PostgreSQL, Elasticsearch, GitHub Actions, Azure, DigitalOcean +""" + +Desired skill section: +"skills":{ + "Programming": ["Python", "C", "C++", "SQL", "Core Java"], + "Tools & Technologies": ["TensorFlow", "PyTorch", "Scikit-learn", "NumPy", "Pandas", "Matplotlib", "Seaborn", "NLTK", "spaCy", "Transformers", "Git", "Docker", "Linux", "PostgreSQL", "Elasticsearch", "GitHub Actions", "Azure", "DigitalOcean"] +} + +3. Extracted Text: +""" +Python, HTML, CSS, JavaScript, React, Node.js, AWS, GCP +""" + +Desired skill section: +"skills":{ + "Default": ["Python", "HTML", "CSS", "JavaScript", "React", "Node.js", "AWS", "GCP"] +} + +Also Modify "_resume_analysis_task" and "resume_analyzer" descriptions if necessary. + +Also apply a guardrail to make sure Agent does not hallucinate. See the documentation given by CrewAI here - "https://docs.crewai.com/v1.15.1/en/enterprise/features/hallucination-guardrail.md" \ No newline at end of file diff --git a/backend/docs/skill_extractor_agent_creation_prompt.txt b/backend/docs/skill_extractor_agent_creation_prompt.txt new file mode 100644 index 0000000..60d0e98 --- /dev/null +++ b/backend/docs/skill_extractor_agent_creation_prompt.txt @@ -0,0 +1,173 @@ +Create a new Crew AI Agent called "skill_extractor" that can extract skills and certifications from a given text like resume, job description or a github profile. It should carefully scan the entire text and find out keywords that match to a skill or certification. Keep in mind that certifications can only be extracted from resumes and not job description or github profiles. Also soft skills can only be extracted from resumes and job_descriptions but not github profiles. It needs to produce the output in a json format as shown below - +""" +{ + "data_source": "resume" or "job description" or "github profile" + "skills": { + "programming_languages": [], + "frameworks": [], + "operating_systems": [], + "cloud_services": [], + "tools": [], + "libraries": [], + "soft_skills": [], + "tech_skills": [], + }, + "certifications": { + "certificate_1": { + "title": "", + "issuer": "", + "issue_date": "", + "expiry_date": "", or "N/A" if it does not expire + "verify_link": "" or "N/A" if not found + }, + "certificate_2": { + "title": + "issuer": + "issue_date": + "expiry_date": + "verify_link": + }, + ... + "certificate_n": { + "title": + "issuer": + "issue_date": + "expiry_date": + "verify_link": + }, + } +} +""" + +Below are examples of complete sample JSONs. +1. +{ + "data_source": "resume", + "skills": { + "programming_languages": ["Python", "C++", "SQL"], + "frameworks": ["TensorFlow", "PyTorch", "Keras"], + "operating_systems": ["Ubuntu Linux", "macOS"], + "cloud_services": ["AWS SageMaker", "Google Cloud AI Platform"], + "tools": ["Docker", "Git", "MLflow", "Jupyter"], + "libraries": ["Scikit-learn", "Pandas", "NumPy", "Matplotlib"], + "soft_skills": ["Problem Solving", "Cross-functional Communication", "Adaptability"], + "tech_skills": ["Model Deployment", "Deep Learning", "Computer Vision", "NLP"] + }, + "certifications": { + "certificate_1": { + "title": "AWS Certified Machine Learning - Specialty", + "issuer": "Amazon Web Services", + "issue_date": "2023-05-15", + "expiry_date": "2026-05-15", + "verify_link": "https://www.credly.com/badges/example-badge-id-ml" + }, + "certificate_2": { + "title": "Deep Learning Specialization", + "issuer": "Coursera (DeepLearning.AI)", + "issue_date": "2022-08-10", + "expiry_date": "N/A", + "verify_link": "https://www.coursera.org/account/accomplishments/specialization/example" + } + } +} + +2. +{ + "data_source": "resume", + "skills": { + "programming_languages": ["Python", "Bash"], + "frameworks": ["Ansible"], + "operating_systems": ["Cisco IOS", "Junos OS", "CentOS"], + "cloud_services": ["AWS VPC", "Azure Virtual Network"], + "tools": ["Wireshark", "SolarWinds", "Cisco Packet Tracer", "Putty"], + "libraries": ["Netmiko", "NAPALM"], + "soft_skills": ["Critical Thinking", "Under Pressure Troubleshooting", "Team Leadership"], + "tech_skills": ["BGP/OSPF Routing", "VLAN/STP Switching", "Firewall Configuration", "Network Automation"] + }, + "certifications": { + "certificate_1": { + "title": "Cisco Certified Network Professional (CCNP) Enterprise", + "issuer": "Cisco", + "issue_date": "2022-11-20", + "expiry_date": "2025-11-20", + "verify_link": "https://www.credly.com/badges/example-badge-id-ccnp" + }, + "certificate_2": { + "title": "CompTIA Network+", + "issuer": "CompTIA", + "issue_date": "2019-04-12", + "expiry_date": "2022-04-12", + "verify_link": "N/A" + } + } +} + +3. +{ + "data_source": "job description", + "skills": { + "programming_languages": ["Python", "R", "Scala", "SQL"], + "frameworks": ["Apache Spark", "Hadoop"], + "operating_systems": ["Linux", "Windows"], + "cloud_services": ["AWS", "GCP BigQuery"], + "tools": ["Tableau", "PowerBI", "Git", "Airflow"], + "libraries": ["SciPy", "Statsmodels", "Seaborn", "NLTK"], + "soft_skills": ["Storytelling", "Business Acumen", "Analytical Thinking"], + "tech_skills": ["Statistical Modeling", "A/B Testing", "Data Mining", "Predictive Analytics"] + }, + "certifications": "N/A" +} + +4. +{ + "data_source": "job description", + "skills": { + "programming_languages": ["Python", "Go", "PowerShell", "C"], + "frameworks": ["Metasploit Framework", "MITRE ATT&CK"], + "operating_systems": ["Kali Linux", "Windows Server", "Debian"], + "cloud_services": ["Azure Security Center", "AWS Shield"], + "tools": ["Burp Suite", "Nessus", "Splunk", "Wireshark"], + "libraries": ["OpenSSL", "Cryptography"], + "soft_skills": ["Attention to Detail", "Ethics and Integrity", "Clear Reporting"], + "tech_skills": ["Penetration Testing", "Incident Response", "Vulnerability Assessment", "Cryptography"] + }, + "certifications": "N/A" +} + +5. +{ + "data_source": "github profile", + "skills": { + "programming_languages": ["JavaScript", "TypeScript", "HTML5", "CSS3"], + "frameworks": ["React.js", "Vue.js", "Next.js"], + "operating_systems": ["macOS", "Windows"], + "cloud_services": ["Vercel", "Netlify", "Firebase"], + "tools": ["Webpack", "Vite", "Figma", "Git"], + "libraries": ["Redux", "Tailwind CSS", "Framer Motion", "React Query"], + "soft_skills": "N/A", + "tech_skills": ["Responsive Web Design", "Web Accessibility (a11y)", "State Management", "SEO Optimization"] + }, + "certifications": "N/A" +} + +6. +{ + "data_source": "github profile", + "skills": { + "programming_languages": ["JavaScript", "Python", "Java", "SQL"], + "frameworks": ["Node.js", "Express.js", "Django", "Spring Boot", "React"], + "operating_systems": ["Ubuntu", "macOS"], + "cloud_services": ["AWS EC2", "AWS S3", "Heroku"], + "tools": ["Docker", "Jenkins", "Postman", "Git"], + "libraries": ["Mongoose", "Axios", "Bcrypt", "Jest"], + "soft_skills": "N/A", + "tech_skills": ["RESTful APIs", "Microservices Architecture", "CI/CD Pipelines", "Database Design (SQL/NoSQL)"] + }, + "certifications": "N/A" +} + + + +Create a well defined task for the same. + +Now instruct the \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 130c70f..c3d3ff4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "beautifulsoup4", "openpyxl", "pandas", + "pdfplumber", "pypdf", "python-docx", "requests", diff --git a/backend/sample_data/Arijit De Resume 2026.docx b/backend/sample_data/Arijit De Resume 2026.docx new file mode 100644 index 0000000..e2fa6c7 Binary files /dev/null and b/backend/sample_data/Arijit De Resume 2026.docx differ diff --git a/backend/sample_data/Arijit De Resume 2026.md b/backend/sample_data/Arijit De Resume 2026.md new file mode 100644 index 0000000..ae4a7e3 --- /dev/null +++ b/backend/sample_data/Arijit De Resume 2026.md @@ -0,0 +1,55 @@ +Arijit De +Kolkata, India +--- + +[\+918981800329](tel:+918981800329) | [arijitde2050@gmail.com](mailto:arijitde2050@gmail.com) | [https://github.com/arijitde92](https://github.com/arijitde92) | [https://www.linkedin.com/in/de-arijit/](https://www.linkedin.com/in/de-arijit/) +--- + +**PROFESSIONAL OVERVIEW** +Machine Learning Engineer with a PhD having 6 years of industry experience spanning Multi-Agent AI systems, Computer Vision and MLOps. Designed and built scalable, robust multi modal RAG pipelines, 3D/2D Image Segmentation and Object Detection solutions across construction, healthcare, and automotive domains. Proficient in Python, FastAPI, Langchain, CrewAI, PyTorch and cloud deployment (GCP, AWS). + +**WORK EXPERIENCE** + +Machine Learning Engineer | [mVizn](https://mvizn.com/), Singapore | Remote JAN 2024 – PRESENT + +* Led development of a multi-agent platform for construction domain (**CrewAI, Python, FastAPI**) powering Autonomous Project Management & Scheduling (APMS), and intelligent project information retrieval — serving 100 concurrent users / 3000 agent runs per day and increasing business efficiency by 50%. +* Hardened multi-agent workflows for reliability by adding failure-mode handling (retries, timeouts, tool-call fallbacks, and output guardrails), reducing task failure rate by 60% and sustaining 99.4% uptime. +* Instrumented end-to-end observability, tracing, and performance monitoring with **CrewAI Tracing** and **DeepEval**, lifting evaluation scores (faithfulness / answer-relevancy) by 75%. +* Built multi-modal Retrieval-Augmented Generation (**RAG**) pipelines over massive text, image, and CAD datasets (10M+ vectors in **Pinecone**) using hybrid search and reranking, achieving retrieval accuracy (recall@k) of 92% while holding retrieval latency under 600 ms. +* Applied **LoRA fine-tuning** to adapt Gemini LLMs for domain understanding and structured output, raising structured-output validity to 98% and cutting token/inference cost per request by 30%. + +Machine Learning Engineer | [Synerjix](http://synerjix.com/), USA | Remote JUN 2025 – DEC 2025 + +* Built a Smart Moving Assistant that estimates shifting cost of items from room photos, improving stakeholder business efficiency by 50%, adopted by 3500 users / 10000 estimates processed. +* Deployed DinoV3-based object detection at 65.3 mAP, serving inference at 10ms via **Python** \+ **FastAPI**, **MySQL**, and **AWS** (SageMaker, EC2, EKS) with CI/CD and **Docker**. + +Computer Vision Engineer | Institute of Neurosciences, India | Onsite JAN 2022 – DEC 2023 + +* Designed and implemented custom 3D Computer Vision solutions for Neurological disorder analysis (**PyTorch, Python, OpenCV, Numpy**), improving diagnosis speed by 40% and reaching 88.68% accuracy on clinical scans. +* Implemented **MLflow**\-based experiment tracking, model versioning, model registry, and reproducible training to support scalable **MLOps**, cutting model iteration/turnaround time by 50%. +* Optimized models with **ONNX** and deployed via Docker and **GCP** (Cloud Storage, Compute Engine VMs, IAM, Cloud SQL), reducing inference latency by 65%. + + +Machine Learning Engineer | Mercedes-Benz Research & Development India, India | Onsite AUG 2018 – SEP 2019 + +* Developed and optimized a computer vision pipeline for Vulnerable Road User (VRU) detection by fine-tuning **YOLO**v3 on large-scale annotated datasets, attaining detection mAP50 of 83% at real-time inference speeds. +* Designed & deployed model training, evaluation, and validation workflows in **AWS**. +* Built automated quality assurance (**QA**) and evaluation frameworks using Python, improving annotation throughput by 20% and data quality by 25%. + + +Systems Engineer | Tata Consultancy Services, India | Onsite AUG 2014 – APR 2015 + +* Maintained high-availability backend systems (Java, MySQL) for TCS’s internal website, ensuring seamless functionality for over 100,000 users. Streamlined feature release cycle, reducing deployment time by 20%. + +**EDUCATION** +PhD | Jadavpur University, India FEB 2020 – APR 2025 +M.Tech. Computer Science & Engineering | Jadavpur University, India | GPA \- 8.83 AUG 2016 – JUN 2018 +B.Tech. Computer Science & Engineering | Techno India, India | GPA \- 8.81 AUG 2010 \- MAY 2014 + +**SKILLS** +Languages: Python, C++, SQL +LLM/Agent Systems: Multi Agent Orchestration (Crew AI, Google ADK, LangGraph), Agentic Memory, + LangChain, LLM, APIs (OpenAI, Gemini, Claude), DeepEval +Frameworks / Libraries: FastAPI, Flask, PyTorch, Scikit-learn, OpenCV, Open3D, PyTest, +Cloud & Ops: Google Cloud Platform (GCP), Amazon Web Services (AWS), Git, Docker, + MLFlow, Github Actions \ No newline at end of file diff --git a/backend/sample_data/Arijit De Resume 2026.pdf b/backend/sample_data/Arijit De Resume 2026.pdf new file mode 100644 index 0000000..00d46b0 Binary files /dev/null and b/backend/sample_data/Arijit De Resume 2026.pdf differ diff --git a/backend/sample_data/Arijit_De_Resume.md b/backend/sample_data/Arijit_De_Resume.md deleted file mode 100644 index 8ca52e5..0000000 --- a/backend/sample_data/Arijit_De_Resume.md +++ /dev/null @@ -1,80 +0,0 @@ - - -| Kolkata, India [arijitde2050@gmail.com](mailto:arijitde2050@gmail.com) [\+918981800329](tel:+918981800329) | Arijit De | [LinkedIn](https://www.linkedin.com/in/de-arijit/) [Google Scholar](https://scholar.google.com/citations?user=mCaigGsAAAAJ&hl=en) [Homepage](https://arijitde92.github.io) | -| :---- | :---: | ----: | - -## **Work Experience** - -**Machine Learning Engineer mVizn Pte. Ltd.** **Jan 2024 – Present** -3D Scan-to-BIM Project Singapore (Remote) - -* Led the development of a comprehensive Deep Learning pipeline for semantic segmentation of 3D point clouds using Python and PyTorch by researching and implementing state of the art models. -* Enhanced model performance by 30% and improved model robustness significantly by extracting relevant features from the data and incorporating data augmentation techniques. -* Led backend development efforts and deployed AI models in GCP using VMs and Cloud Storage, integrating Python and MySQL, which resulted in a 60% improvement in the client’s system performance and scalability. -* Implemented role-based access control (RBAC) using GCP Identity and Access Management (IAM) to securely manage user permissions for the dashboard that monitored and visualized ML model outputs. -* Resolved over 10 critical issues post-deployment by troubleshooting AI model behavior; collaborated with 5+ stakeholders to align technical solutions with business requirements and enhance user experience. - -**Machine Learning Engineer Mercedes-Benz Research and Development India** **Aug 2018 – Jan 2020** -Advanced driver-assistance system (ADAS) Bengaluru, India - -* Developed a suite of manual quality control tools using PyQt to validate over 10,000 annotated images for Vulnerable Road User (VRU) detection, improving annotation accuracy by 25%. -* Automated quality checks and data analysis/visualization of annotated data using python scripts which increased annotation throughput by 20%. -* Created an AI system to detect VRU by training Yolo v3 model with the above mentioned annotated images that helped in improving Level 3 ADAS vehicle capabilities by 30%. - -**Systems Engineer Tata Consultancy Services** **Aug 2014 – August 2015** -Ultimatix Team Kolkata, India - -* Maintained and enhanced TCS’s internal website, “Ultimatix,” by implementing over 30 feature requests across frontend (HTML, Bootstrap CSS, JavaScript) and backend (Java, MySQL) components, ensuring seamless functionality for 100,000+ users. -* Collaborated with cross-functional teams, including QA and UI/UX designers, to streamline the feature release cycle, reducing deployment time by 20%. - -## **Skills** - -* Languages: Python, C++, SQL -* Technologies: PyTorch, Tensorflow, Scikit-Learn, OpenCV, Numpy, Pandas, Flask, Git, API -* Cloud: Google Cloud Platform (GCP) -* Other: Agile methodology, Problem solving attitude, Collaborative, Punctual. - -## **Education** - -* PhD, Jadavpur University, Kolkata, India. 2020–2024 -* M.Tech. Computer Science & Engineering, Jadavpur University, Kolkata, India. GPA \- 8.83 2016–2018 -* B.Tech. Computer Science & Engineering, Techno India, Kolkata, India. GPA \- 8.81 2010–2014 - -## **Projects** - -* **Online Assignment Submission Portal** (Oct '24 \- Nov ‘24) \- A Flask \+ MySQL WebApp for upload, submission & checking of C programming assignments. Teachers can create assignments whereas students can view those assignments, submit the code and get it evaluated with a built in GCC compiler.. - Live link \- [https://subsequent-heath-arijit-home-3b9d0400.koyeb.app/](https://subsequent-heath-arijit-home-3b9d0400.koyeb.app/) - Github \- [https://github.com/arijitde92/Online\_Programming\_Assignment\_Portal](https://github.com/arijitde92/Online_Programming_Assignment_Portal) -* **Spiritual Chatbot** (Jan’24 \- Feb’24) \- An LLM chatbot that personifies a Hindu God. Uses OpenAI GPT-3.5 turbo model APIs and Langchain for creating prompt templates. Attempted prompt engineering techniques to make chatbot stay within context and purpose. The front end was built using streamlit while python was at backend. - Live link \- [https://spiritualchatbot.streamlit.app/](https://spiritualchatbot.streamlit.app/) Github \- [https://github.com/arijitde92/SpiritualChatBot](https://github.com/arijitde92/SpiritualChatBot) -* **Github Code Analysis Tool** (Apr' 23 \- Jun' 23\) \- An LLM based github repository analyzer that can tell the most technically complex repository among all the repositories of a given github user. Built using OpenAI GPT3.5 turbo, Langchain, DeepLake vector database, Python and Flask. - Github \- [https://github.com/arijitde92/Github\_Code\_Analysis\_Tool](https://github.com/arijitde92/Github_Code_Analysis_Tool) -* **3D Brain Analysis and Visualization App** (Jul’24 \- Aug’24) \- A Flask Application that can segment the hippocampus or a brain tumor from a 3D brain MRI scan. The segmentation models are built using Hippocampus Segmentation Factory (HSF) for Hippocampus Segmentation and a 3D Attention UNet model for brain tumor segmentation. - Github \- [https://github.com/arijitde92/Brain\_Seg\_App](https://github.com/arijitde92/Brain_Seg_App) - -## **Publications** - -1. **A. De**, A. S. Chowdhury, (2025). Shape Induced Multi-class Deep Graph Cut for Hippocampus Subfield Segmentation. In: Antonacopoulos, A., Chaudhuri, S., Chellappa, R., Liu, CL., Bhattacharya, S., Pal, U. (eds) Pattern Recognition. (***ICPR 2024\)***. Lecture Notes in Computer Science, vol 15313\. Springer, Cham. https://doi.org/10.1007/978-3-031-78201-5\_16 -2. **A. De**, N. Das, PK. Saha, A Comellas, E Hoffman, S Basu, T Chakraborti. MSO-GP: 3-D segmentation of large and complex conjoined tree structures. ***Methods**. **2024*** June 3;229:9-16. doi: 10.1016/j.ymeth.2024.05.016. Epub ahead of print. PMID: 38838947\. -3. **A. De**, P. Ebenezer, "Sleep Apnea sub-type detection from Polysomnography signals," 2024 IEEE International Conference on Interdisciplinary Approaches in Technology and Management for Social Innovation (***IATMSI 2024***), Gwalior, India, 2024, pp. 1-6 -4. **A. De**, M. Tiwari, & A. S. Chowdhury. 3D Hippocampus Segmentation Using a Hog Based Loss Function with Majority Pooling. In 2023 IEEE International Conference on Image Processing (***ICIP***), Kuala Lumpur, Malaysia ***2023***, October, pp. 2260-2264. -5. **A. De**, R. Mhatre, M. Tiwari and A. S. Chowdhury, "Brain Tumor Classification from Radiology and Histopathology using Deep Features and Graph Convolutional Network," 2022 26th International Conference on Pattern Recognition (***ICPR 2022***), Montreal, QC, Canada, *2022*, pp. 4420-4426 -6. **A. De**, M. Tiwari, E. Grisan, A.S. Chowdhury, "A Deep Graph Cut Model for 3D Brain Tumor Segmentation", 44th Annual International Conference of the IEEE Engineering in Medicine & Biology Society (***EMBC 2022***). -7. **A. De**, A.S. Chowdhury, "DTI based Alzheimer's Disease Classification with Rank Modulated Fusion of CNNs and Random Forest", ***Expert Syst. Appl**.* 169 (*2021*), 114338\. DOI: 10.1016/j.eswa.2020.114338 - -## **Voluntary Activities** - -* Selected as a mentee for the IEEE EMBS Student Mentorship Program (SMP) 2023, gaining hands-on experience in healthcare-related signal processing. -* Contributed to research on sleep disorder diagnostics and co-authored a publication on Sleep Apnea Detection using Machine Learning, achieving a 94.26% accuracy in classifying four stages of Sleep Apnea. - -## **Open Source Contributions** - -* Contributed to [AI-Code](https://github.com/Avdhesh-Varshney/AI-Code/pull/30) as part of Social Summer of Code (**SSoC**) 2024 & [DL-Simplified](https://github.com/abhisheks008/DL-Simplified/pull/451) as part of Social Winter of Code (**SWoC**) 2023 Hackathons. -* Contributed to [nexB](https://github.com/nexB) and [TheAlgorithms](https://github.com/TheAlgorithms/Python) repositories pertaining to both feature enhancements and documentations. - - -## **Certifications** - -* Deep Learning, a 5-course specialization, by Deeplearning.ai. Verify [here](https://coursera.org/share/9cdc757ce1bb41134bb22a7548aff40b). -* TensorFlow in Practice Specialization, by Deeplearning.ai. Verify [here](https://coursera.org/share/be06afd8c51589a64831567f8424fe9b). -* MCPS: Microsoft Certified Professional, Microsoft Certification No.: 1042C4-5DHF30 \ No newline at end of file diff --git a/backend/sample_data/Yash_Pal_Resume.pdf b/backend/sample_data/Yash_Pal_Resume.pdf new file mode 100644 index 0000000..8d24d1d Binary files /dev/null and b/backend/sample_data/Yash_Pal_Resume.pdf differ diff --git a/backend/sample_data/Yash_Raj_Singh_Oct_Resume.pdf b/backend/sample_data/Yash_Raj_Singh_Oct_Resume.pdf new file mode 100644 index 0000000..dae2d29 Binary files /dev/null and b/backend/sample_data/Yash_Raj_Singh_Oct_Resume.pdf differ diff --git a/backend/sample_data/interview_materials.md b/backend/sample_data/interview_materials.md deleted file mode 100644 index fbc1cb4..0000000 --- a/backend/sample_data/interview_materials.md +++ /dev/null @@ -1,112 +0,0 @@ -## Interview Questions and Talking Points for Arijit De - -This document outlines key interview questions and tailored talking points designed to highlight Arijit De's strengths and align his extensive experience with the Artificial Intelligence Engineer role at hoichoi. - ---- - -### **General Experience & Production Deployment** - -**Interview Question:** -"hoichoi is looking for an AI Engineer with hands-on experience building and deploying ML/AI systems in production, not just experimentation. Can you share a specific project where you took a model from initial concept or experimentation all the way to a live, production environment?" - -**Talking Points for Arijit:** -* **mVizn Pte. Ltd.:** Emphasize leading the *end-to-end development and deployment* of a Deep Learning pipeline for 3D point cloud semantic segmentation. Highlight the tangible impact: "enhanced model performance by 30% and improved system robustness." -* **Deployment on GCP:** Detail how you "directed backend development and deployed AI models on GCP (VMs, Cloud Storage) with Python and MySQL integration," leading to a "60% improvement in system performance and scalability." -* **Mercedes-Benz R&D India:** Discuss developing and *deploying an AI system* for Vulnerable Road User (VRU) detection using a Yolo v3 model, improving Level 3 ADAS capabilities by 30% in real-world scenarios. -* **Ownership:** Stress your full-stack involvement, from model training to serving, monitoring, and iteration in a production setting. - ---- - -### **Platform Intelligence: ML Model Design, Data Pipelines & APIs** - -**Interview Question:** -"The role involves designing, training, and deploying ML models for diverse applications like content recommendations, audience segmentation, and content performance prediction. Can you discuss your experience with similar predictive or analytical modeling tasks, and how you approach building robust data pipelines and APIs to support these models?" - -**Talking Points for Arijit:** -* **Model Design & Training:** - * **mVizn:** Discuss the complex 3D point cloud semantic segmentation, emphasizing deep learning expertise with PyTorch. Relate this to the underlying principles of classification and prediction needed for recommendations and segmentation. - * **Mercedes-Benz:** Highlight the Yolo v3 model for object detection, demonstrating experience with real-time, high-performance predictive models. - * **Skills:** Mention proficiency in PyTorch and TensorFlow, and your certifications, as foundational for advanced model development. -* **Data Pipelines:** - * **mVizn:** Explain how you "directed backend development and deployed AI models on GCP... with Python and MySQL integration," which involved building and owning pipelines to feed data into the intelligence layer. - * **Job Application Agent:** Reference using "Google Cloud BigQuery" for data warehousing, demonstrating practical experience with data ingestion and processing at scale. - * **Skills:** Emphasize your experience with "Data Ingestion" and "ETL," asserting your ability to handle structured and unstructured signals effectively. -* **APIs & Model Serving:** - * **mVizn:** Highlight "Developed APIs and model-serving infrastructure so that AI-driven insights are directly consumable by various teams," directly addressing the job requirement. - * **3D Brain Analysis and Visualization App:** Mention using Flask to build a REST API for model serving, demonstrating hands-on API development skills. - ---- - -### **Platform Intelligence: Model Monitoring & Iteration** - -**Interview Question:** -"Once models are deployed, it's crucial to instrument, monitor, and iterate on their performance. How do you approach monitoring model performance post-deployment, handling issues, and designing A/B tests for model-driven features?" - -**Talking Points for Arijit:** -* **Monitoring & Issue Resolution:** - * **mVizn:** Detail how you "resolved critical post-deployment issues" and "implemented GCP IAM for secure role-based access control and model monitoring," demonstrating proactive management of production models. - * **Skills:** Point to "Model Evaluation" as a core skill, indicating a systematic approach to assessing model effectiveness. -* **A/B Testing:** - * **Skills:** Explicitly mention "A/B Testing" as a skill, indicating familiarity with experimental design for model-driven features. You can discuss the importance of controlled experiments in validating model improvements. - ---- - -### **Creative AI: Generative AI & LLMs** - -**Interview Question:** -"hoichoi is building an AI-native creative studio, LoglineAI, which involves GenAI workflows for script analysis, content tagging, and creative ideation. Can you walk us through your practical experience with LLMs and GenAI, specifically focusing on prompt engineering, fine-tuning, RAG architectures, and evaluating model outputs?" - -**Talking Points for Arijit:** -* **Job Application Agent:** This is a strong example. Detail how you "developed an AI agent using LangChain, Crew AI, Google Cloud BigQuery, and Vertex AI for automated job application tasks," explicitly mentioning the implementation of "RAG architectures to enhance agent's knowledge retrieval and contextual understanding." -* **Spiritual Chatbot:** Discuss how you "utilized OpenAI GPT-3.5 turbo and LangChain for prompt engineering to develop a conversational LLM capable of providing spiritual guidance," and how this project involved "fine-tuning and evaluating model outputs." -* **Github Code Analysis Tool:** Highlight employing "OpenAI GPT-3.5 turbo, LangChain, and DeepLake vector database to build a tool for code analysis," showcasing further experience with "RAG systems" and vector databases. -* **Skills:** Reinforce your proficiency in "Generative AI (GenAI)," "Large Language Models (LLMs)," "Prompt Engineering," "Fine-tuning," and "RAG Architectures." - ---- - -### **Creative AI: Translating Ambiguous Problems** - -**Interview Question:** -"Working with product and creative leads often means translating ambiguous creative problems into well-scoped AI features. Can you provide an example of how you've collaborated with non-technical stakeholders to understand a need, define an AI solution, and then prototype and productionize it?" - -**Talking Points for Arijit:** -* **mVizn Pte. Ltd.:** Emphasize "collaborating with 5+ stakeholders to align technical solutions with business needs and ensure continuous model monitoring and iteration." This demonstrates your ability to bridge technical and business/creative gaps. -* **Prototyping & Iteration:** Discuss your general approach to rapid prototyping and validation, drawing from your project experience where you would have received feedback and iterated on solutions. Your problem-solving attitude is key here. -* **Mercedes-Benz R&D India:** Mention creating "manual quality control tools with PyQt for validating annotated images," which required understanding the needs of annotators (non-technical users) and building tools to improve their workflow. - ---- - -### **Platform & Infrastructure: MLOps & Responsible AI** - -**Interview Question:** -"Maintaining and evolving a shared MLOps foundation is critical for an AI-native company. What is your experience with MLOps tools like model registries, experiment tracking, feature stores, and deployment pipelines? Additionally, how do you champion responsible AI practices, including bias audits and explainability?" - -**Talking Points for Arijit:** -* **MLOps Foundation:** - * **mVizn:** Your experience deploying and managing AI models on GCP (VMs, Cloud Storage, IAM) inherently involved MLOps practices. Elaborate on how you ensured robust deployment, monitoring, and iteration. - * **Skills:** Explicitly list "MLOps Tools: Model Registry, Experiment Tracking, Feature Store, Deployment Pipelines" as skills, demonstrating your understanding of these components. - * **Job Application Agent:** Mention using "Vertex AI," which is GCP's MLOps platform, further solidifying practical experience. -* **Responsible AI Practices:** - * **Skills:** Highlight "Bias Audits," "Output Logging," "Human-in-the-Loop," and "Explainability" as skills, demonstrating awareness and commitment. - * **Mercedes-Benz R&D India:** Discuss creating "manual quality control tools" and "automated quality checks" for annotated images. This shows a proactive approach to ensuring data quality and mitigating potential biases at the source. - * **mVizn:** Mention "resolved critical post-deployment issues," which often involves understanding why a model might be failing or producing unexpected outputs, linking to explainability and debugging for quality. - * **Academic Background:** Briefly mention your PhD research, which likely instilled a rigorous and ethical approach to data analysis and model development. - ---- - -### **Technical Skills & AI Engineering Tools** - -**Interview Question:** -"The role requires strong proficiency in Python, ML frameworks, data pipeline tools, cloud infrastructure, SQL, and vector databases. Additionally, we're looking for someone who is a default user of AI engineering tools like LangChain and has experience debugging model outputs to protect quality. Can you elaborate on your skills in these areas?" - -**Talking Points for Arijit:** -* **Python, ML Frameworks, Data Processing:** - * **Resume Summary:** Reiterate your "Strong foundation in Python; proficiency with ML frameworks (PyTorch, TensorFlow), data processing libraries (Pandas, Numpy), and REST API development (Flask)." Provide examples from mVizn, Mercedes-Benz, and projects. -* **Data Pipeline Tools, Cloud Infra, SQL, Vector Databases:** - * **Cloud:** Emphasize extensive "Google Cloud Platform (GCP)" experience (VMs, Cloud Storage, IAM, Vertex AI). - * **SQL:** Mention "MySQL" used at mVizn and TCS, and "Google Cloud BigQuery" in your Job Application Agent project. - * **Vector Databases:** Highlight "DeepLake" vector database used in the Github Code Analysis Tool, directly addressing this requirement. - * **Data Pipelines:** Reiterate your "Data Ingestion" and "ETL" experience, and how your pipeline ownership at mVizn and BigQuery usage serve as "equivalent experience to Airflow/dbt." -* **AI Engineering Tools & Debugging:** - * **AI Tools:** Directly reference "LangChain," "Crew AI," and "Vertex AI" from your Job Application Agent project, demonstrating hands-on experience with modern AI engineering tools. - * **Debugging/Quality:** Discuss how you "resolved critical post-deployment issues" at mVizn, which involved debugging model behavior. Also, mention "Created manual quality control tools with PyQt for validating annotated images" and "Automated quality checks" at Mercedes-Benz, showcasing your commitment to ensuring output quality and accuracy. \ No newline at end of file diff --git a/backend/tests/run_resume_analyzer_test.py b/backend/tests/run_resume_analyzer_test.py new file mode 100644 index 0000000..8ef483f --- /dev/null +++ b/backend/tests/run_resume_analyzer_test.py @@ -0,0 +1,177 @@ +""" +Standalone quality-check script for the resume_analyzer agent. + +Runs the resume analysis agent + task in isolation (no GitHub task, no +profiler, no GCS upload, no database write): the parsed-resume JSON is +written to local temp storage by the save_parsed_resume tool and then copied +to tests/output/test_parsed_resume__.json for inspection. + +By default it runs against all three sample resumes; pass --resume to run a +single custom file instead. + +Not a pytest module — run it directly: + + cd backend + uv run python tests/run_resume_analyzer_test.py + uv run python tests/run_resume_analyzer_test.py --resume "sample_data/Arijit De Resume 2026.docx" + +Requires GEMINI_API_KEY (loaded from backend/.env). +""" + +import argparse +import os +import shutil +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv + +BACKEND_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND_DIR)) + +load_dotenv(BACKEND_DIR / ".env") + +DEFAULT_RESUMES = [ + BACKEND_DIR / "sample_data" / "Yash_Raj_Singh_Oct_Resume.pdf", + BACKEND_DIR / "sample_data" / "Yash_Pal_Resume.pdf", + BACKEND_DIR / "sample_data" / "Arijit De Resume 2026.pdf", +] +OUTPUT_DIR = Path(__file__).resolve().parent / "output" + + +def _sanitize_stem(name: str) -> str: + """Make a filesystem-safe stem for the output filename.""" + return "".join(c if c.isalnum() else "_" for c in name).strip("_") or "resume" + + +def analyze_resume(resume_file: Path) -> bool: + """Run the resume_analyzer on one file. Returns True on success.""" + # Imports that need sys.path/.env ready. + from crewai import Crew + + from app.services.crew.agents import resume_analyzer + from app.services.crew.resume_tools import ResumeAnalysisContext + from app.services.crew.tasks import _resume_analysis_task + from app.services.resume_parser import ( + ParsedResume, + ResumeParsingError, + extract_resume_text, + ) + + # 1. Extract text (pypdf → pdfplumber fallback / python-docx / raw md). + print(f"Extracting text from: {resume_file}") + try: + resume_text = extract_resume_text(str(resume_file)) + except ResumeParsingError as e: + print(f"ERROR: resume parsing failed: {e}") + return False + print(f"Extracted {len(resume_text)} characters.") + + tmp_md = tempfile.NamedTemporaryFile( + mode="w", suffix=".md", prefix="test_resume_", delete=False, encoding="utf-8" + ) + with tmp_md as f: + f.write(resume_text) + + try: + # 2. Run the agent + task in isolation. local_only=True → the save + # tool writes to temp storage only (no GCS upload, no DB write). + ctx = ResumeAnalysisContext( + user_id=0, resume_id=0, user_name="test_user", local_only=True, + resume_text=resume_text, + ) + task = _resume_analysis_task(ctx, run_async=False) + crew = Crew(agents=[resume_analyzer], tasks=[task], verbose=True) + crew.kickoff(inputs={"resume_path": tmp_md.name}) + + if not ctx.state.get("saved"): + print("FAILED: the agent never saved the parsed resume.") + return False + + # 3. Validate and copy to tests/output/. + parsed = ParsedResume.model_validate_json(ctx.state["json"]) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = ( + OUTPUT_DIR + / f"test_parsed_resume_{_sanitize_stem(resume_file.stem)}_{timestamp}.json" + ) + shutil.copy(ctx.state["local_path"], output_path) + + result = parsed.result + filled = sum( + 1 for value in result.model_dump().values() + if value not in ("", None, [], False, 0) + ) + print("\n" + "=" * 70) + print(f"SUCCESS — parsed resume copied to: {output_path}") + print(f" name: {result.name!r}") + print(f" email: {result.email!r}") + print(f" positions: {len(result.positions)}") + print(f" education: {len(result.education_qualifications)}") + print(f" projects: {len(result.projects)}") + print(f" pubs: {len(result.publications)}") + print(f" years_exp: {result.years_of_experience}") + print(f" filled top-level fields: {filled}/{len(result.model_dump())}") + print("=" * 70) + return True + finally: + if os.path.exists(tmp_md.name): + os.unlink(tmp_md.name) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run the resume_analyzer agent in isolation.") + parser.add_argument( + "--resume", + default=None, + help="Path to a single .pdf/.docx/.md resume. Omit to run all three " + "sample resumes.", + ) + args = parser.parse_args() + + if not os.environ.get("GEMINI_API_KEY"): + print("ERROR: GEMINI_API_KEY is not set (expected in backend/.env).") + return 1 + + # Resolve the list of resumes to process. + if args.resume: + resume_file = Path(args.resume) + if not resume_file.is_absolute(): + resume_file = BACKEND_DIR / resume_file + resumes = [resume_file] + else: + resumes = list(DEFAULT_RESUMES) + + results: list[tuple[Path, bool]] = [] + for i, resume_file in enumerate(resumes, start=1): + print("\n" + "#" * 70) + print(f"# Resume {i}/{len(resumes)}: {resume_file.name}") + print("#" * 70) + if not resume_file.exists(): + print(f"ERROR: resume file not found: {resume_file}") + results.append((resume_file, False)) + continue + try: + ok = analyze_resume(resume_file) + except Exception as e: # noqa: BLE001 — one bad resume shouldn't stop the rest + print(f"ERROR: unexpected failure on {resume_file.name}: {e}") + ok = False + results.append((resume_file, ok)) + + # Summary. + print("\n" + "=" * 70) + print("SUMMARY") + for resume_file, ok in results: + print(f" [{'PASS' if ok else 'FAIL'}] {resume_file.name}") + passed = sum(1 for _, ok in results if ok) + print(f" {passed}/{len(results)} succeeded") + print("=" * 70) + + return 0 if passed == len(results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/uv.lock b/backend/uv.lock index 7c51ca3..63cb2ac 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -377,6 +377,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, ] +[[package]] +name = "cel-python" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-re2" }, + { name = "jmespath" }, + { name = "lark" }, + { name = "pendulum" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/4e/f821948a5bbd7a98a218720f831a62216f79a98e43b13d9ab2f98e37c5f8/cel_python-0.5.0.tar.gz", hash = "sha256:3eb0a619e8df0f338d0430cda01427a742e77e3c433a1c7c3ebd409cd804c45a", size = 13364027, upload-time = "2026-01-31T19:07:13.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/f8/38812adc3f787c2c2e8ba56f524185ed379656c10b40347a32796ba61c08/cel_python-0.5.0-py3-none-any.whl", hash = "sha256:d0f85008b89655c2bb18d797d2fa3f96f2ed80f4a3b43b0e8138c6646581e5f6", size = 84950, upload-time = "2026-01-31T19:07:11.821Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -610,12 +626,13 @@ wheels = [ [[package]] name = "crewai" -version = "1.14.7" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, { name = "aiosqlite" }, { name = "appdirs" }, + { name = "cel-python" }, { name = "chromadb" }, { name = "click" }, { name = "crewai-cli" }, @@ -644,9 +661,9 @@ dependencies = [ { name = "tomli" }, { name = "tomli-w" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/f7/5f3182520d418708468613f0d74d8e01831f79ad6416b4eb37b10ada8fb2/crewai-1.14.7.tar.gz", hash = "sha256:85e35d4290470c9fe434c35e58055407a36c2600a0465add589133769febb371", size = 7665248, upload-time = "2026-06-11T17:14:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/7c/0d962c1e1f84a37eda183fc40a0b7e7a49c74969a378a0fda4c4c9bfcd18/crewai-1.15.2.tar.gz", hash = "sha256:deb2d882105cb9075cdd2198ac8398f9001315f2abc28d815fec66b003a2acbf", size = 7767892, upload-time = "2026-07-08T02:06:07.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/de/2b553b4b67f2044c3ca3d69949a715655e62e2eecd61bb40bfc4b9c9f62a/crewai-1.14.7-py3-none-any.whl", hash = "sha256:6deeaa883927f76f02b2c417494572f9fbd9873c5a2b82ae733062b83ddc0da8", size = 1007553, upload-time = "2026-06-11T17:14:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/9b/85/ab49bac9104c72ad76a42c8203f43ff5473575bd4f99b4e9cbfbef84a76a/crewai-1.15.2-py3-none-any.whl", hash = "sha256:3e84f8ef873a2e4953ecd83368f22b99e2e2e1f58e11677a4c9f041560f666d7", size = 1065130, upload-time = "2026-07-08T02:06:04.85Z" }, ] [package.optional-dependencies] @@ -656,7 +673,7 @@ anthropic = [ [[package]] name = "crewai-cli" -version = "1.14.7" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -676,14 +693,14 @@ dependencies = [ { name = "tomli-w" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/6e/afd4a96296c1add13e92957702fe2cd74a52f79d5e183c2846183fa0d1eb/crewai_cli-1.14.7.tar.gz", hash = "sha256:cb8f363a77cbe05f73bfda388f971d53b66bc3f37f84addafc1af0cdd43d6af4", size = 111556, upload-time = "2026-06-11T17:14:44.827Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/eb/e233022bef33b9e1a33e4b49524b9d18095bb9d3aea3e1f257c38fc957c2/crewai_cli-1.15.2.tar.gz", hash = "sha256:f11f31cdfd35817d8f5d4dcc29794c6374e6f89efd6188ae5f969b81f05cc1d1", size = 213554, upload-time = "2026-07-08T02:06:09.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/11/7b72aee4a9fe820924114448f66e8172b4bc104c61227ed24241b1ceb04b/crewai_cli-1.14.7-py3-none-any.whl", hash = "sha256:01c073e0a1db5a56f830af20dc85816f43da820d738850103c9a8d50d834588e", size = 111463, upload-time = "2026-06-11T17:14:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/15/aa/fd3dfa687952b4e77dbf5a24fd98adee48df2a23f5f5b1b89f9a9cceaf03/crewai_cli-1.15.2-py3-none-any.whl", hash = "sha256:93ebe8ea734be793178737105897f60f3df0e8f114f27d523a28500c9c5946c4", size = 185882, upload-time = "2026-07-08T02:06:08.591Z" }, ] [[package]] name = "crewai-core" -version = "1.14.7" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -699,14 +716,14 @@ dependencies = [ { name = "rich" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/1e/6ecde20a9761e2492f973f9f89b667da57f613d26d615a3d12962774e489/crewai_core-1.14.7.tar.gz", hash = "sha256:c8c16f29abb515380c45b729c22fca3ade29a51d25211ce5ac63b50f7af7179e", size = 20908, upload-time = "2026-06-11T17:14:46.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/31/81c458f8d7ded2a2a45dfed96d4bd99de5eac60ae4d4ab1c14c6fdd23887/crewai_core-1.15.2.tar.gz", hash = "sha256:43cecd3a4de8a781264fe882575065ab21688906ce400a29a2363c292e1387b1", size = 23416, upload-time = "2026-07-08T02:06:11.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/71/e551211ae4e4e3fc0c32cd31b097719c12d3841f84fe86cc557aa76eef2d/crewai_core-1.14.7-py3-none-any.whl", hash = "sha256:244a16baff22d6925a46f87114d32c14494c687dd97479be5467a3a432e7e4d6", size = 28877, upload-time = "2026-06-11T17:14:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0a/3518397b1aeeee9534e1e1f307812653254a622b66aae0007d4a0f849bbd/crewai_core-1.15.2-py3-none-any.whl", hash = "sha256:2f5cfa39396833c8451bf334a8e5f8d0c7c8f6a378bada148af5ff5713f9e858", size = 30960, upload-time = "2026-07-08T02:06:10.928Z" }, ] [[package]] name = "crewai-tools" -version = "1.14.7" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -718,9 +735,9 @@ dependencies = [ { name = "tiktoken" }, { name = "youtube-transcript-api" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f6/48b9de65470ca5857b6513500864a4b25bc0fe630ad7d8dd762fecd39f31/crewai_tools-1.14.7.tar.gz", hash = "sha256:7b19aec91d86a08a31da8b7753d8a8b74e22f1bbac96c75e182295f7a1daf948", size = 895021, upload-time = "2026-06-11T17:14:51.598Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/e9/df7d16fde5c644d9e63c31c44088218558a35acd40c96c3bc972e05fcebd/crewai_tools-1.15.2.tar.gz", hash = "sha256:2290a2f41794fde0861fc784ff150841786968ea17112b47db70a1460690ba69", size = 898337, upload-time = "2026-07-08T02:06:16.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/57/2ceb2cfec8845e3d9e358c441cb382f82c9b3b8b964384d665e290e565b0/crewai_tools-1.14.7-py3-none-any.whl", hash = "sha256:e6f1b0a36b40a43ba57c42519cf9320c59a937cfd1a5c110246ae0c7bc99d595", size = 809653, upload-time = "2026-06-11T17:14:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/6afb41b4ea756e9b6ec598f7dc3456a887a0327a68fe814678527c0266a7/crewai_tools-1.15.2-py3-none-any.whl", hash = "sha256:6ae2990eed5fd6d1328010f128e93307145b29e2eeed69592be4d91126b5f4ef", size = 811463, upload-time = "2026-07-08T02:06:14.926Z" }, ] [package.optional-dependencies] @@ -1289,6 +1306,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, ] +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/c441722196598fc3de0f654606ad9975a968c71dc27f516b5a4c9ebb94fd/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:9f3cf610e857a7d6f02916cf2b7fc159a5429b8bcb23164500d46e5e233f2924", size = 485549, upload-time = "2025-11-05T14:57:36.939Z" }, + { url = "https://files.pythonhosted.org/packages/ea/87/cf588255e5ada1dfb555cc96de35be78438bb0b6faba64df5fe91cecc224/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a21c2807bf4d5d00f206a4ecb3b043aad674e28c451b697b740280f608872078", size = 518840, upload-time = "2025-11-05T14:57:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/0d/39/da66e4ca9be0c51546efc6fb39cf1683c4be8245d8199cb54a9808e8d5fa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8314144eefeee7b88b742081c2038418f677e63901039ca9dbfbc0c5bb6d2911", size = 487037, upload-time = "2025-11-05T14:57:39.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/dd/24ba65692dd58dca6ff178428551f4e9b776d1489a1251f5c8539e598baa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:28a46be978e53c772139d0f5c9ba69f53563fcdd4225407e4d34d51208b828f1", size = 520285, upload-time = "2025-11-05T14:57:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/61/12/cfdbb92bed24af6474970a75a26145c424f98cfbcc633fdd185985f0efe0/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e", size = 482981, upload-time = "2025-11-05T14:57:41.928Z" }, + { url = "https://files.pythonhosted.org/packages/97/bf/5fc32ded9279e69a87b88d7261e7e77e2e26325d4e27ca1303a3215e430a/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1920b15dc9b1bdfeca5aa2c60900373c6f27cd1056d53cd299456ea5540a6fff", size = 510366, upload-time = "2025-11-05T14:57:43.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/f927ddc7aef1b8d7ccc8a649c335d311f29f3dea658209e30e37720e4891/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87", size = 572390, upload-time = "2025-11-05T14:57:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8c/23075e589038284c9487f41cde531d35873f9da622fb4ac7d1d97bd9086e/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b", size = 591386, upload-time = "2025-11-05T14:57:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/858453ef689f6b9895cd02b466836a9d1a6e4ba535d1a275b01bf73baa1d/google_re2-1.1.20251105-1-cp313-cp313-win32.whl", hash = "sha256:67c5c73d7ebcf3f0e0a3b528b41bd8c6c04900f1598aebf05bbdf15a06cf5f9a", size = 433807, upload-time = "2025-11-05T14:57:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/08/24/6ea87fe682e115ffd296e91eb5c5a266349d1ee8414ce8ece3f99ec1ac84/google_re2-1.1.20251105-1-cp313-cp313-win_amd64.whl", hash = "sha256:0bcba63ad3ea8926fb0c71bb5044e33d405bb9395f5b5444393cd5f28f0bf6d3", size = 491734, upload-time = "2025-11-05T14:57:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/34/85/32ba71b06f3cf5f9856ae95b3d6463b971742453631a5ae2c5be338ea377/google_re2-1.1.20251105-1-cp313-cp313-win_arm64.whl", hash = "sha256:64ee189ea857f2126c5e42073cfa9b03e9f4cbaf073edbedb575059074841aa0", size = 642654, upload-time = "2025-11-05T14:57:49.602Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7f/7eb238bdcd06182b5f427afd305cf413b7cf4ea71047308bbf35912cf923/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:cc151cf6a585d9ebe711da32b23683fcff40f78db8c8587c7f4b209ef4658809", size = 484719, upload-time = "2025-11-05T14:57:51.326Z" }, + { url = "https://files.pythonhosted.org/packages/6d/62/eed28eab67f939f4b9383c47b1db11638ade6ac30785c15cb960de85ba43/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:7e2186d2c90488c1e11895343941f35ca2f58e9ba6c6b034fd531abe22ef77cc", size = 517698, upload-time = "2025-11-05T14:57:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/f7/16/a1e6768513f788bf9c67a1cfe379ef34a793983eee46e4b653e42b558b78/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:41be22359c3dceb582937739b4365dd8e279de24ad0a5b10e653503abaff2ed7", size = 486421, upload-time = "2025-11-05T14:57:53.852Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fc/7a97ffd36d451e5a8bfaff2f9022b14807795d588f98227ff96e8da99856/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f3168d7bbac247c862ea85b2f3c011d3a04bedcb6892b37f14d488f4133b206e", size = 519037, upload-time = "2025-11-05T14:57:55.078Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/8b6f7d94bb689dafdf60de8dd8f8f6296ad40d4d15c933fcda4da7a3a06b/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b", size = 483373, upload-time = "2025-11-05T14:57:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a6/16a09e03d1de128f821869e4252688c21319f5017d9209f4d0e71ea5c951/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:0476b07421b8882b279d5ceb5b760c15c62d581ded95274697fc1227e3869ee6", size = 510167, upload-time = "2025-11-05T14:57:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9d/213dce5de401527369fb5af11096b18c06001d9eb71f3318fe5eba1ec706/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81", size = 573176, upload-time = "2025-11-05T14:57:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/03/be/a8def96aa4a80b233e105767d22e3de961dcde5a04f0a05cb4f3ddb4df78/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116", size = 591483, upload-time = "2025-11-05T14:58:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/144bbc4b9359da89aec07b4c2a91a6bfe7119914885386577c665b07bb01/google_re2-1.1.20251105-1-cp314-cp314-win32.whl", hash = "sha256:214c1accdc60fff9ce1bf812b157147ca361844f496ed9e0d5f357b0e562ced8", size = 433773, upload-time = "2025-11-05T14:58:01.594Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/74e301211699f1b650ba7690a3e4e52146ac4266fcd62f3ea0a945b9eda4/google_re2-1.1.20251105-1-cp314-cp314-win_amd64.whl", hash = "sha256:6d4d5fdadd329a2ed193463899d00ef2fd126172f36a4c01c9def271f19801b6", size = 491893, upload-time = "2025-11-05T14:58:02.969Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" }, +] + [[package]] name = "google-resumable-media" version = "2.10.0" @@ -1636,18 +1705,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - [[package]] name = "importlib-resources" version = "7.1.0" @@ -1790,6 +1847,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "job-application-agent-backend" version = "1.0.0" @@ -1810,6 +1876,7 @@ dependencies = [ { name = "openpyxl" }, { name = "pandas" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pdfplumber" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pymysql" }, @@ -1848,6 +1915,7 @@ requires-dist = [ { name = "openpyxl" }, { name = "pandas" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pdfplumber" }, { name = "pydantic", extras = ["email"], specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pymysql", specifier = ">=1.1.0" }, @@ -2238,6 +2306,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/13/8186a9867c67f3fef9958a1d60b45f46c1a9b5d28f67d8fd136f28ceab3f/langsmith-0.8.16-py3-none-any.whl", hash = "sha256:081e57c0175d142192683288740a796eb0eb32d9e703b4bf9133678ceefe3286", size = 500303, upload-time = "2026-06-15T17:41:22.33Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -2907,32 +2984,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2943,14 +3019,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/f7/bb63837a3edb9ca857aaf5760796874e7cecddc88a2571b0992865a48fb6/opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd", size = 22566, upload-time = "2025-06-10T08:55:23.214Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/42/0a4dd47e7ef54edf670c81fc06a83d68ea42727b82126a1df9dd0477695d/opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6", size = 18615, upload-time = "2025-06-10T08:55:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2961,48 +3037,48 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" }, + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.55b1" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, ] [[package]] @@ -3234,6 +3310,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/9a/07d658e1e7fad860f1c541ab941348125dbdab773be3a0afaf32361866c7/pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580", size = 60047, upload-time = "2026-06-15T03:31:29.702Z" }, ] +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/27/a4be6ec12161b503dd036f8d7cc57f8626170ae31bb298038be9af0001ce/pendulum-3.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5d775cc608c909ad415c8e789c84a9f120bb6a794c4215b2d8d910893cf0ec6a", size = 337923, upload-time = "2026-01-30T11:20:51.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/2a214e18355ec2a6ce3f683a97eecdb6050866ff3a6cf165d411450aeb1b/pendulum-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8de794a7f665aebc8c1ba4dd4b05ab8fe1a36ce9c0498366adf1d1edd79b2686", size = 327379, upload-time = "2026-01-30T11:20:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/01/7392e58ebc1d9e70b987dc8bb0c89710b47ac8125067efe7aa4c420b616f/pendulum-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bac7df7696e1c942e17c0556b3a7bcdd1d7aa5b24faee7620cb071e754a0622", size = 340115, upload-time = "2026-01-30T11:20:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/80de84c5ca1a3e4f7f3b75090c9b61b6dbb6d095e302ee592cebbaf0bbfb/pendulum-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0f6a8a04475d9cba26ce701e7d66d266fd97227f2f5f499270eba04be1c7e9", size = 373969, upload-time = "2026-01-30T11:20:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/f7b4c1818927ab394a2a0a9b7011f360a0a75839a22678833c5bc0a84183/pendulum-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c352c63c1ff05f2198409b28498d7158547a8be23e1fbd4aa2cf5402fb239b55", size = 379058, upload-time = "2026-01-30T11:20:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/36/94/9947cf710620afcc68751683f2f8de88d902505e7c13c0349d7e9d362f97/pendulum-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de8c1ad1d1aa7d4ceae341528bab35a0f8c88a5aa63f2f5d84e16b517d1b32c2", size = 348403, upload-time = "2026-01-30T11:20:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/0e6ba0bb00fa57907af2a3fca8643bded5dba1e87072d50673776a0d6ed2/pendulum-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1ba955511c12fec2252038b0c866c25c0c30b720bf74d3023710f121e42b1498", size = 517457, upload-time = "2026-01-30T11:21:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fe/dae5fbfe67bd41d943def0ad8f1e7f6988aa8e527255e433cd7c494f9ad5/pendulum-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4115bf364a2ec6d5ddc476751ceaa4164a04f2c15589f0d29aa210ddb784b15d", size = 561103, upload-time = "2026-01-30T11:21:03.924Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a0/8f646160b98abfc19152505af19bd643a4279ec2bdbe0959f16b7025fc6b/pendulum-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:4151a903356413fdd9549de0997b708fb95a214ed97803ffb479ffd834088378", size = 260595, upload-time = "2026-01-30T11:21:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/79/01/feead7af9ded7a13f2d798fb6573e70f469113eafcd8cc8f59671584ca3e/pendulum-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:acfdee9ddc56053cb7c8c075afbfde0857322d09e56a56195b9cd127fae87e4c", size = 255382, upload-time = "2026-01-30T11:21:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/5b9cc823862450910bcb2c7cdc6884c0939b268639146d30e4a4f55eb1f1/pendulum-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c17ac069e88c5a1e930a5ae0ef17357a14b9cc5a28abadda74eaa8106d241c8e", size = 338281, upload-time = "2026-01-30T11:21:40.812Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/64a35260f6ac36c0ad50eeb5f1a465b98b0d7603f79a5c2077c41326d639/pendulum-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1fbb540edecb21f8244aebfb05a1f2333ddc6c7819378c099d4a61cc91ae93c", size = 328030, upload-time = "2026-01-30T11:21:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/da/6b/1140e09310035a2afb05bb90a2b8fbda9d3222e03b92de9533123afe6b65/pendulum-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c67fb9a1fe8fc1adae2cc01b0c292b268c12475b4609ff4aed71c9dd367b4d", size = 340206, upload-time = "2026-01-30T11:21:44.148Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/a493de56cbc24a64b21ac6ba98513a9ec5c67daa3dba325e39a8e53f30d8/pendulum-3.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:baa9a66c980defda6cfe1275103a94b22e90d83ebd7a84cc961cee6cbd25a244", size = 373976, upload-time = "2026-01-30T11:21:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/f083c4fd1a161d4ab218680cc906338c541497b3098373f2241f58c429cb/pendulum-3.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef8f783fa7a14973b0596d8af2a5b2d90858a55030e9b4c6885eb4284b88314f", size = 380075, upload-time = "2026-01-30T11:21:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/333a0fcb33bf15eb879a46a11ce6300c1698a141e689665fe430783ff8d6/pendulum-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d2e9bfb065727d8676e7ada3793b47a24349500a5e9637404355e482c822be", size = 349026, upload-time = "2026-01-30T11:21:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/dfb526ec0cba1e7cd6a5e4f4dd64a6ada7428d1449c54b15f7b295f6e122/pendulum-3.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:55d7ba6bb74171c3ee409bf30076ee3a259a3c2bb147ac87ebb76aaa3cf5d3a2", size = 517395, upload-time = "2026-01-30T11:21:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/b4f2b5f1200351c4869b8b46ad5c21019e3dbe0417f5867ae969fad7b5fe/pendulum-3.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a50d8cf42f06d3d8c3f8bb2a7ac47fa93b5145e69de6a7209be6a47afdd9cf76", size = 561926, upload-time = "2026-01-30T11:21:51.698Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/567376582da58f5fe8e4f579db2bcfbf243cf619a5825bdf1023ad1436b3/pendulum-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e5bbb92b155cd5018b3cf70ee49ed3b9c94398caaaa7ed97fe41e5bb5a968418", size = 258817, upload-time = "2026-01-30T11:21:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/dfffd7eb50d67fa821cd4d92cf71575ead6162930202bc40dfcedf78c38c/pendulum-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:d53134418e04335c3029a32e9341cccc9b085a28744fb5ee4e6a8f5039363b1a", size = 253292, upload-time = "2026-01-30T11:21:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0d/d5ac8468a1b40f09a62d6e91654088de432367907579dd161c0fb1bdf222/pendulum-3.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9585594d32faa71efa5a78f576f1ee4f79e9c5340d7c6f0cd6c5dfe725effaaa", size = 338760, upload-time = "2026-01-30T11:22:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/7fa8c8be6caac8e0be78fbe7668df571f44820ed779cb3736fab645fcba8/pendulum-3.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26401e2de77c437e8f3b6160c08c6c5d45518d906f8f9b48fd7cb5aa0f4e2aff", size = 328333, upload-time = "2026-01-30T11:22:13.811Z" }, + { url = "https://files.pythonhosted.org/packages/ad/78/73a1031b7d1bf7986e8e655cea3f018164b3470aecfea25a4074e77dda73/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637e65af042f383a2764a886aa28ccc6f853bf7a142df18e41c720542934c13b", size = 340841, upload-time = "2026-01-30T11:22:15.278Z" }, + { url = "https://files.pythonhosted.org/packages/49/40/4e36e9074e92b0164c088b9ada3c02bfea386d83e24fa98b30fe9b6e61a8/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e46c28f4d067233c4a4c42748f4ffa641d9289c09e0e81488beb6d4b3fab51", size = 348959, upload-time = "2026-01-30T11:22:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/8bf7fcb91b526e1efe17d047faa845709b88800fff915ff848ff26054293/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:71d46bcc86269f97bfd8c5f1475d55e717696a0a010b1871023605ca94624031", size = 518102, upload-time = "2026-01-30T11:22:18.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b0/a36c468d2d0dec62ddea7c5e4177e93abb12f48ac90f09f24d0581c5189f/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5cd956d4176afc7bfe8a91bf3f771b46ff8d326f6c5bf778eb5010eb742ebba6", size = 561884, upload-time = "2026-01-30T11:22:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4d/dad105261898907bf806cabca53d3878529a9fa2c0d5d7f95f2035246fc2/pendulum-3.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:39ef129d7b90aab49708645867abdd207b714ba7bff12dae549975b0aca09716", size = 261236, upload-time = "2026-01-30T11:22:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -5396,15 +5532,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/95/129ea37efd6cd6ed00f62baae6543345c677810b8a3bf0026756e1d3cf3c/youtube_transcript_api-1.2.4-py3-none-any.whl", hash = "sha256:03878759356da5caf5edac77431780b91448fb3d8c21d4496015bdc8a7bc43ff", size = 485227, upload-time = "2026-01-29T09:09:15.427Z" }, ] -[[package]] -name = "zipp" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, -] - [[package]] name = "zstandard" version = "0.25.0" diff --git a/frontend/src/components/ProgressTracker.jsx b/frontend/src/components/ProgressTracker.jsx index 72c39fd..ba5dee0 100644 --- a/frontend/src/components/ProgressTracker.jsx +++ b/frontend/src/components/ProgressTracker.jsx @@ -3,6 +3,7 @@ import { FiCheck, FiLoader, FiClock, FiAlertTriangle } from 'react-icons/fi'; const STEPS = [ { key: 'extracting_job_info', label: 'Extracting job information' }, + { key: 'parsing_resume', label: 'Parsing your resume' }, { key: 'searching_projects', label: 'Searching GitHub projects' }, { key: 'building_profile', label: 'Building your profile' }, { key: 'tailoring_resume', label: 'Tailoring your resume' }, diff --git a/frontend/src/components/ResumeUpload.jsx b/frontend/src/components/ResumeUpload.jsx index 5e8978a..a1609b6 100644 --- a/frontend/src/components/ResumeUpload.jsx +++ b/frontend/src/components/ResumeUpload.jsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react'; import api from '../services/api'; -import { FiUploadCloud, FiFile, FiTrash2, FiExternalLink, FiEye, FiDownload } from 'react-icons/fi'; +import { FiUploadCloud, FiFile, FiTrash2, FiEye, FiDownload } from 'react-icons/fi'; import ResumePreviewModal from './ResumePreviewModal'; export default function ResumeUpload({ resumes, onRefresh }) { @@ -12,8 +12,8 @@ export default function ResumeUpload({ resumes, onRefresh }) { const handleUpload = async (file) => { if (!file) return; - if (!file.name.endsWith('.md')) { - setError('Only Markdown (.md) files are accepted.'); + if (!/\.(md|pdf|docx)$/i.test(file.name)) { + setError('Only Markdown (.md), PDF (.pdf) or Word (.docx) files are accepted.'); return; } setError(''); @@ -41,9 +41,17 @@ export default function ResumeUpload({ resumes, onRefresh }) { } }; + const isMarkdown = (name) => /\.md$/i.test(name || ''); + const handleDownload = async (resume) => { setError(''); try { + if (!isMarkdown(resume.original_filename)) { + // Binary resumes (.pdf/.docx) are served via a GCS signed URL. + const res = await api.get(`/resumes/${resume.id}/preview`); + window.open(res.data.signed_url, '_blank', 'noopener'); + return; + } const res = await api.get(`/resumes/${resume.id}/content`); const blob = new Blob([res.data.content], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); @@ -59,6 +67,21 @@ export default function ResumeUpload({ resumes, onRefresh }) { } }; + const handleView = async (resume) => { + setError(''); + if (isMarkdown(resume.original_filename)) { + setPreviewId(resume.id); + return; + } + // The markdown modal can't render binaries — open the signed URL instead. + try { + const res = await api.get(`/resumes/${resume.id}/preview`); + window.open(res.data.signed_url, '_blank', 'noopener'); + } catch (err) { + setError(err.response?.data?.detail || 'Preview failed.'); + } + }; + const handleDrop = (e) => { e.preventDefault(); setDragActive(false); @@ -91,22 +114,12 @@ export default function ResumeUpload({ resumes, onRefresh }) { {uploading ? 'Uploading...' : 'Drag & drop or click to upload'}

- Only .md files accepted + .md, .pdf and .docx files accepted

- handleUpload(e.target.files[0])} /> - {/* Conversion links */} - - {error &&

{error}

} {/* Resume list */} @@ -123,7 +136,7 @@ export default function ResumeUpload({ resumes, onRefresh }) { {r.original_filename} -