Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,6 @@ logs/
client_secret_*.json
*-service-account*.json
project-*.json
backend/credentials/
backend/credentials/
**:Zone.Identifier
backend/tests/output/
6 changes: 6 additions & 0 deletions backend/app/api/v1/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions backend/app/api/v1/resumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,19 +20,21 @@
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(
file: UploadFile = 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
Expand Down Expand Up @@ -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}
Expand Down
23 changes: 23 additions & 0 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down
17 changes: 17 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user_id>/<filename>.md
gcs_path = Column(String(500), nullable=False) # gs://bucket/uploads/<user_id>/<filename>
# gs://bucket/parsed/<user_id>/<name>_<user_id>_<resume_id>_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
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
44 changes: 42 additions & 2 deletions backend/app/services/crew/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@
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"),
temperature=0.7,
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()
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions backend/app/services/crew/crew.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -32,29 +34,38 @@ 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`.

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.
tracing: Enable CrewAI tracing.
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)

Expand Down
Loading