diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..df80d655b1959601c0173c8976d981e9d8cfa066 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000000000000000000000000000000000000..e0d0858f266ec27b243e8b92301fc7002e1f2745 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/__pycache__/env.cpython-312.pyc b/alembic/__pycache__/env.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96e92f06aa3ca896ab6013ccfc190ceda1badb70 Binary files /dev/null and b/alembic/__pycache__/env.cpython-312.pyc differ diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000000000000000000000000000000000000..dc2919724d648730fed61bddb5f69807e9afb5ef --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,95 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.core.config import settings +from app.core.db import Base +# Import all models so Alembic can discover them +import app.models.resume +import app.models.analysis + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +config.set_main_option("sqlalchemy.url", settings.database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..11016301e749297acb67822efc7974ee53c905c6 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/8606f9c05795_initial_tables.py b/alembic/versions/8606f9c05795_initial_tables.py new file mode 100644 index 0000000000000000000000000000000000000000..550cd53a0bbb7ecad9a394ba4d23ba95323144ad --- /dev/null +++ b/alembic/versions/8606f9c05795_initial_tables.py @@ -0,0 +1,59 @@ +"""Initial tables + +Revision ID: 8606f9c05795 +Revises: +Create Date: 2026-07-12 19:11:23.136578 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8606f9c05795' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('resumes', + sa.Column('id', sa.String(), nullable=False), + sa.Column('session_id', sa.String(), nullable=False), + sa.Column('file_name', sa.String(), nullable=False), + sa.Column('file_path', sa.String(), nullable=False), + sa.Column('extracted_text', sa.Text(), nullable=False), + sa.Column('pages', sa.Integer(), nullable=False), + sa.Column('uploaded_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_resumes_session_id'), 'resumes', ['session_id'], unique=False) + op.create_table('analyses', + sa.Column('id', sa.String(), nullable=False), + sa.Column('resume_id', sa.String(), nullable=False), + sa.Column('session_id', sa.String(), nullable=False), + sa.Column('job_description', sa.Text(), nullable=False), + sa.Column('target_role', sa.String(), nullable=False), + sa.Column('result', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['resume_id'], ['resumes.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_analyses_resume_id'), 'analyses', ['resume_id'], unique=False) + op.create_index(op.f('ix_analyses_session_id'), 'analyses', ['session_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_analyses_session_id'), table_name='analyses') + op.drop_index(op.f('ix_analyses_resume_id'), table_name='analyses') + op.drop_table('analyses') + op.drop_index(op.f('ix_resumes_session_id'), table_name='resumes') + op.drop_table('resumes') + # ### end Alembic commands ### diff --git a/alembic/versions/__pycache__/8606f9c05795_initial_tables.cpython-312.pyc b/alembic/versions/__pycache__/8606f9c05795_initial_tables.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe82630b85bb898c08cc1f506b516480511604aa Binary files /dev/null and b/alembic/versions/__pycache__/8606f9c05795_initial_tables.cpython-312.pyc differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8167e67dbd0e627be7ed11fd26a8e93b76f7312 Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a67f44829d24f5472005085df0db93b9976c9d43 Binary files /dev/null and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/api/__pycache__/__init__.cpython-312.pyc b/app/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b0bb76f5d36bc04145009eae7bfa64073bb37f7 Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/api/__pycache__/deps.cpython-312.pyc b/app/api/__pycache__/deps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91681456ac59914c2c05974e22fb0958e49ff4c3 Binary files /dev/null and b/app/api/__pycache__/deps.cpython-312.pyc differ diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..04f2f6c9d129f1fc9685cb7cbbedf02fce8eddd3 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,13 @@ +import uuid +from typing import Annotated + +from fastapi import Header + + +async def get_session_id(x_session_id: Annotated[str | None, Header()] = None) -> str: + """ + Return the session ID from the X-Session-Id header. + If the header is absent, generate a fresh UUID so every resource + is still scoped to *some* session. + """ + return x_session_id or str(uuid.uuid4()) diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/api/routes/__pycache__/__init__.cpython-312.pyc b/app/api/routes/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73fd5b5ac441d0e6c7f2b2ee3d37f986b669303e Binary files /dev/null and b/app/api/routes/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/api/routes/__pycache__/analysis.cpython-312.pyc b/app/api/routes/__pycache__/analysis.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bed5a69e8d1921da05abda517789cc933731a76e Binary files /dev/null and b/app/api/routes/__pycache__/analysis.cpython-312.pyc differ diff --git a/app/api/routes/__pycache__/health.cpython-312.pyc b/app/api/routes/__pycache__/health.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7f9ee8817f97bf0718df2f271c610cfe61db447 Binary files /dev/null and b/app/api/routes/__pycache__/health.cpython-312.pyc differ diff --git a/app/api/routes/__pycache__/resume.cpython-312.pyc b/app/api/routes/__pycache__/resume.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..406b3826392d3d413a9feed60a7c7ff69c182b1d Binary files /dev/null and b/app/api/routes/__pycache__/resume.cpython-312.pyc differ diff --git a/app/api/routes/analysis.py b/app/api/routes/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..fb789ab5802101483b5af9c188b4fdff956b5528 --- /dev/null +++ b/app/api/routes/analysis.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import get_session_id +from app.core.logging import get_logger +from app.models.analysis import AnalysisRecord +from app.models.resume import ResumeRecord +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.db import get_db +from app.schemas.analysis import ( + AnalysisDetail, + AnalyzeRequest, + AnalyzeResponse, + BulletRewrite, + ComponentBreakdown, + Recommendation, + ScoreWeights, + SectionNote, +) +from app.services.ats_scoring import ATSResult, run_ats_scoring + +logger = get_logger(__name__) +router = APIRouter() + + +# ── POST /api/analyze ───────────────────────────────────────────────────── + +@router.post( + "/analyze", + response_model=AnalyzeResponse, + tags=["analysis"], + summary="Analyse resume against a job description", +) +async def analyze( + body: AnalyzeRequest, + session_id: Annotated[str, Depends(get_session_id)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> AnalyzeResponse: + """ + Run the full ATS-style analysis pipeline: + 1. Fetch extracted resume text. + 2. Parse sections. + 3. Extract and compare keywords. + 4. Compute semantic similarity. + 5. Compute weighted ATS score. + 6. Return structured result. + + Bullet rewrites are empty in Phase 2 — LLM populates them in Phase 4. + """ + # ── Fetch resume ────────────────────────────────────────────────────── + record = await db.get(ResumeRecord, body.resume_id) + if record is None or record.session_id != session_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resume not found in this session.", + ) + + logger.info( + "analyze: resume_id=%s session=%s jd_chars=%d role=%r", + body.resume_id, session_id, len(body.job_description), body.target_role, + ) + + # ── Run scoring pipeline ────────────────────────────────────────────── + try: + result: ATSResult = run_ats_scoring( + resume_text=record.extracted_text, + jd_text=body.job_description, + target_role=body.target_role, + ) + except Exception as exc: + logger.exception("Scoring pipeline failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Analysis failed — please retry.", + ) from exc + + # ── Build response ──────────────────────────────────────────────────── + response = _build_response(result, record.file_name) + + # ── LLM Bullet Rewrites (Phase 4) ───────────────────────────────────── + import asyncio + from app.services.llm_service import rewrite_weak_bullets + + try: + # Run sync Gemini call in threadpool to avoid blocking ASGI loop + rewrites_raw = await asyncio.to_thread( + rewrite_weak_bullets, + bullets=result.parsed.experience_bullets, + job_description=body.job_description, + target_role=body.target_role, + max_rewrites=3 + ) + # Map to Pydantic objects + response.bullet_rewrites = [BulletRewrite(**rw) for rw in rewrites_raw] + except Exception as exc: + logger.warning("LLM rewrite failed: %s", exc) + # Non-fatal, just leave rewrites empty + pass + + # ── Persist ─────────────────────────────────────────────────────────── + ar = AnalysisRecord( + resume_id=body.resume_id, + session_id=session_id, + job_description=body.job_description, + target_role=body.target_role, + result=response.model_dump(), + ) + db.add(ar) + await db.commit() + await db.refresh(ar) + response.analysis_id = ar.id + + logger.info( + "analyze: analysis_id=%s overall=%d latency_ms=%d", + ar.id, result.overall_score, result.latency_ms, + ) + + return response + + +# ── GET /api/analysis/{analysis_id} ────────────────────────────────────── + +@router.get( + "/analysis/{analysis_id}", + response_model=AnalysisDetail, + tags=["analysis"], + summary="Get past analysis result", +) +async def get_analysis( + analysis_id: str, + session_id: Annotated[str, Depends(get_session_id)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> AnalysisDetail: + """Fetch an analysis result generated in this session.""" + ar = await db.get(AnalysisRecord, analysis_id) + if ar is None or ar.session_id != session_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Analysis not found in this session.", + ) + return AnalysisDetail( + analysis_id=ar.id, + resume_id=ar.resume_id, + created_at=ar.created_at, + result=AnalyzeResponse(**ar.result), + ) + + +# ── Private helpers ─────────────────────────────────────────────────────── + +def _role_fit(score: int) -> tuple[str, str]: + if score >= 80: + return ( + "Strong fit", + "Resume aligns well with the JD across skills and experience. " + "Fix the missing keywords and quantify a few bullets to push into shortlist range.", + ) + if score >= 65: + return ( + "Moderate fit", + "Core skills overlap but presentation and specificity are weak. " + "Prioritise the top-3 missing keywords and add measurable outcomes.", + ) + return ( + "Needs work", + "Significant gaps in skills or presentation. " + "Address missing keywords first, then rewrite bullets around measurable outcomes.", + ) + + +def _recommendations(result: ATSResult, file_name: str) -> list[Recommendation]: + recs: list[Recommendation] = [] + comp = result.components + kw = result.keywords + + if kw.missing: + top3 = ", ".join(kw.missing[:3]) + recs.append(Recommendation( + priority="high", category="keywords", + message=f"Add missing keywords: {top3}. These appear in the JD but not in your resume.", + )) + + if comp.experience_alignment < 60: + recs.append(Recommendation( + priority="high", category="experience", + message="Quantify at least 5 bullets with numbers, percentages, or impact metrics.", + )) + + if comp.semantic_similarity < 55: + recs.append(Recommendation( + priority="high", category="alignment", + message="Rephrase your summary and experience to mirror the language and priorities in the JD.", + )) + + if not result.parsed.summary: + recs.append(Recommendation( + priority="medium", category="structure", + message="Add a 2-line summary at the top tailored to this specific role.", + )) + + if not result.parsed.certifications_raw: + recs.append(Recommendation( + priority="low", category="certifications", + message="Even one relevant certification improves ATS signal and fills a structural gap.", + )) + + if kw.weak: + recs.append(Recommendation( + priority="medium", category="keywords", + message=f"Strengthen underused keywords: {', '.join(kw.weak[:3])}. Mention them in context.", + )) + + return recs[:6] # cap at 6 recommendations + + +def _build_response(result: ATSResult, file_name: str) -> AnalyzeResponse: + verdict, summary = _role_fit(result.overall_score) + comp = result.components + + return AnalyzeResponse( + analysis_id="", # filled in after persist + overall_score=result.overall_score, + weights=ScoreWeights(), + components=ComponentBreakdown( + keyword_coverage=comp.keyword_coverage, + semantic_similarity=comp.semantic_similarity, + skills_overlap=comp.skills_overlap, + experience_alignment=comp.experience_alignment, + resume_quality=comp.resume_quality, + ), + matched_keywords=result.keywords.matched, + missing_keywords=result.keywords.missing, + weak_keywords=result.keywords.weak, + section_notes=[ + SectionNote( + section=sn.section, + status=sn.status, + note=sn.note, + score=sn.score, + ) + for sn in result.section_notes + ], + recommendations=_recommendations(result, file_name), + bullet_rewrites=[], # Phase 4 (LLM) + role_fit_verdict=verdict, + role_fit_summary=summary, + parsed_sections=result.parsed.to_dict(), + latency_ms=result.latency_ms, + ) diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000000000000000000000000000000000000..b2aa61eceaf8e302450c7d21ab333b855b52d793 --- /dev/null +++ b/app/api/routes/health.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + + +class HealthResponse(BaseModel): + status: str + + +@router.get("/health", response_model=HealthResponse, tags=["meta"]) +async def health() -> HealthResponse: + """Basic liveness probe.""" + return HealthResponse(status="ok") diff --git a/app/api/routes/resume.py b/app/api/routes/resume.py new file mode 100644 index 0000000000000000000000000000000000000000..8f14f16e1def5b974cb7901093936caf7d1186ae --- /dev/null +++ b/app/api/routes/resume.py @@ -0,0 +1,135 @@ +import time +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status + +from app.api.deps import get_session_id +from app.core.config import settings +from app.core.logging import get_logger +from app.models.resume import ResumeRecord +from app.schemas.resume import ResumeDetail, ResumeUploadResponse +from app.services.pdf_extractor import ExtractionError, extract_text + +logger = get_logger(__name__) +router = APIRouter() + +_ALLOWED_CONTENT_TYPES = {"application/pdf"} + + +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.db import get_db + +@router.post( + "/resumes/upload", + response_model=ResumeUploadResponse, + status_code=status.HTTP_201_CREATED, + tags=["resumes"], + summary="Upload a resume PDF", +) +async def upload_resume( + file: UploadFile, + session_id: Annotated[str, Depends(get_session_id)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResumeUploadResponse: + """ + Accept a PDF resume, extract its text, persist the record, and return + an ID for subsequent analysis requests. + + Error codes + ----------- + 400 : wrong file type or file too large + 422 : PDF is unreadable or appears to be scanned / image-only + """ + t0 = time.perf_counter() + + # ── 1. Validate content type ────────────────────────────────────────── + if file.content_type not in _ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Please upload a PDF resume.", + ) + + # ── 2. Read and validate size ───────────────────────────────────────── + data = await file.read() + if len(data) > settings.max_upload_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Max size is {settings.max_upload_mb} MB.", + ) + + # ── 3. Persist to disk under session-scoped directory ───────────────── + session_dir: Path = settings.upload_dir / session_id + session_dir.mkdir(parents=True, exist_ok=True) + + safe_name = Path(file.filename or "resume.pdf").name # strip path traversal + dest = session_dir / safe_name + dest.write_bytes(data) + logger.info("Saved %s (%d bytes) for session %s", safe_name, len(data), session_id) + + # ── 4. Extract text ─────────────────────────────────────────────────── + try: + extracted_text, pages = extract_text(dest) + except ExtractionError as exc: + # Remove the file so the client can retry with a better PDF + dest.unlink(missing_ok=True) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + # ── 5. Store record ─────────────────────────────────────────────────── + record = ResumeRecord( + session_id=session_id, + file_name=safe_name, + file_path=str(dest), + extracted_text=extracted_text, + pages=pages, + ) + db.add(record) + await db.commit() + await db.refresh(record) + + elapsed_ms = round((time.perf_counter() - t0) * 1000) + logger.info( + "upload_resume: resume_id=%s pages=%d chars=%d elapsed_ms=%d", + record.id, + pages, + len(extracted_text), + elapsed_ms, + ) + + return ResumeUploadResponse( + resume_id=record.id, + file_name=safe_name, + pages_estimate=pages, + extraction_status="success", + ) + + +@router.get( + "/resumes/{resume_id}", + response_model=ResumeDetail, + tags=["resumes"], + summary="Get resume metadata", +) +async def get_resume( + resume_id: str, + session_id: Annotated[str, Depends(get_session_id)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResumeDetail: + """Return metadata for a previously uploaded resume (session-scoped).""" + record = await db.get(ResumeRecord, resume_id) + if record is None or record.session_id != session_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resume not found in this session.", + ) + return ResumeDetail( + resume_id=record.id, + file_name=record.file_name, + pages=record.pages, + extraction_status="success", + uploaded_at=record.uploaded_at, + char_count=len(record.extracted_text), + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/__pycache__/__init__.cpython-312.pyc b/app/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a9b982e60aa2dbeaba82accde1de7829b077006 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/core/__pycache__/config.cpython-312.pyc b/app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fee30f70c4cc1d05442b3aa4919c4fa691e9db6f Binary files /dev/null and b/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/app/core/__pycache__/db.cpython-312.pyc b/app/core/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ddc10fd59b0c13e6a8b0c90ab3679127e63cc52 Binary files /dev/null and b/app/core/__pycache__/db.cpython-312.pyc differ diff --git a/app/core/__pycache__/logging.cpython-312.pyc b/app/core/__pycache__/logging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2f65d75f72878003eda0f85e819152bd8f4ef88 Binary files /dev/null and b/app/core/__pycache__/logging.cpython-312.pyc differ diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ac80cff7ed1086309b56dc09c3ea96898569cb78 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,32 @@ +from pathlib import Path +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Upload limits + max_upload_mb: int = 5 + + # Where uploaded files land on disk + upload_dir: Path = Path("./uploads") + + # LLM (Phase 4) + gemini_api_key: str = "" + + # Database (Phase 2+) + database_url: str = "sqlite+aiosqlite:///./resume.db" + + @property + def max_upload_bytes(self) -> int: + return self.max_upload_mb * 1024 * 1024 + + +settings = Settings() + +# Make sure the upload directory exists at startup +settings.upload_dir.mkdir(parents=True, exist_ok=True) diff --git a/app/core/db.py b/app/core/db.py new file mode 100644 index 0000000000000000000000000000000000000000..5463a3b92d018d20dacafb628cb29095e7e61667 --- /dev/null +++ b/app/core/db.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +from app.core.config import settings + +# Engine setup +engine = create_async_engine( + settings.database_url, + echo=False, + future=True, + connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +) + +# Session factory +async_session_maker = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False, autoflush=False +) + +Base = declarative_base() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Dependency for getting async database sessions.""" + async with async_session_maker() as session: + yield session diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..e5728fe4f87ed94bfc12f0e3a5525bb18005a070 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,22 @@ +import logging +import sys + + +def setup_logging(level: int = logging.INFO) -> None: + """Configure structured application logging.""" + fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%dT%H:%M:%S")) + + root = logging.getLogger() + root.setLevel(level) + # Avoid duplicate handlers if called more than once (e.g. in tests) + if not root.handlers: + root.addHandler(handler) + + # Quieten noisy libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..27542f68f3ead92360340350363ece634244d16a --- /dev/null +++ b/app/main.py @@ -0,0 +1,56 @@ +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import health, resume, analysis +from app.core.config import settings +from app.core.logging import setup_logging + +setup_logging() + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + """Startup / shutdown hook.""" + # Ensure upload directory exists (config.py also does this at import time, + # but this is the canonical place for startup side-effects). + settings.upload_dir.mkdir(parents=True, exist_ok=True) + yield + + +app = FastAPI( + title="AI Resume Reviewer API", + description=( + "Hybrid ATS-style resume analysis: rules + embeddings + LLM. " + "Phase 0/1 — ingestion and extraction only." + ), + version="0.1.0", + lifespan=lifespan, +) + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Allow the Vite dev server (8081) and any localhost variant during development. +# In production, lock this to your actual frontend domain. +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:8080", + "http://localhost:8081", + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://127.0.0.1:8081", + "http://127.0.0.1:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Session-Id"], +) + +# ── Routers ─────────────────────────────────────────────────────────────────── +app.include_router(health.router, prefix="/api") +app.include_router(resume.router, prefix="/api") +app.include_router(analysis.router, prefix="/api") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/__pycache__/__init__.cpython-312.pyc b/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84f82c4555877d6dd9a84c3980cdf90718523f89 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/models/__pycache__/analysis.cpython-312.pyc b/app/models/__pycache__/analysis.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b9910510542a031647d92c646b7d524b2787f45 Binary files /dev/null and b/app/models/__pycache__/analysis.cpython-312.pyc differ diff --git a/app/models/__pycache__/resume.cpython-312.pyc b/app/models/__pycache__/resume.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..157c7dcf37dffa214ece492092e970c18ef6b559 Binary files /dev/null and b/app/models/__pycache__/resume.cpython-312.pyc differ diff --git a/app/models/analysis.py b/app/models/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..5043e7173eeadaea7c74c655811a6211e6e63da5 --- /dev/null +++ b/app/models/analysis.py @@ -0,0 +1,35 @@ +""" +SQLAlchemy analysis model (Phase 5). +""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.sqlite import JSON as SQLiteJSON +from sqlalchemy.types import JSON + +from app.core.db import Base, engine + + +def _new_id() -> str: + return f"an_{uuid.uuid4().hex[:12]}" + + +# Handle JSON fallback for SQLite vs Postgres +JsonType = SQLiteJSON if engine.url.drivername == "sqlite" else JSON + + +class AnalysisRecord(Base): + __tablename__ = "analyses" + + id = Column(String, primary_key=True, default=_new_id) + resume_id = Column(String, ForeignKey("resumes.id", ondelete="CASCADE"), nullable=False, index=True) + session_id = Column(String, index=True, nullable=False) + job_description = Column(Text, nullable=False) + target_role = Column(String, nullable=False) + result = Column(JsonType, nullable=False) + created_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) diff --git a/app/models/resume.py b/app/models/resume.py new file mode 100644 index 0000000000000000000000000000000000000000..661b9b349d0eb17c147b4d1b0cf279a4f32f2f10 --- /dev/null +++ b/app/models/resume.py @@ -0,0 +1,29 @@ +""" +SQLAlchemy resume model (Phase 5). +""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Integer, String, Text + +from app.core.db import Base + + +def _new_id() -> str: + return f"res_{uuid.uuid4().hex[:12]}" + + +class ResumeRecord(Base): + __tablename__ = "resumes" + + id = Column(String, primary_key=True, default=_new_id) + session_id = Column(String, index=True, nullable=False) + file_name = Column(String, nullable=False) + file_path = Column(String, nullable=False) + extracted_text = Column(Text, nullable=False) + pages = Column(Integer, nullable=False) + uploaded_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/__pycache__/__init__.cpython-312.pyc b/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ddaedb331065c56e67702a87317a30db9f2a7e00 Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/analysis.cpython-312.pyc b/app/schemas/__pycache__/analysis.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6160e70475caf044f51df482adce2ab375f02f6f Binary files /dev/null and b/app/schemas/__pycache__/analysis.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/resume.cpython-312.pyc b/app/schemas/__pycache__/resume.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c4c7bcf7b6f10c402f14933572221145d0aba0a Binary files /dev/null and b/app/schemas/__pycache__/resume.cpython-312.pyc differ diff --git a/app/schemas/analysis.py b/app/schemas/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..ff3d7b3689a8d902cc3aaaf53bbc0bd2a20d10d2 --- /dev/null +++ b/app/schemas/analysis.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +# ── Request ─────────────────────────────────────────────────────────────── + +class AnalyzeRequest(BaseModel): + resume_id: str + job_description: str = Field(min_length=100, max_length=10_000) + target_role: str = "" + + +# ── Response sub-types ──────────────────────────────────────────────────── + +class ScoreWeights(BaseModel): + keyword_coverage: float = 0.30 + semantic_similarity: float = 0.25 + skills_overlap: float = 0.20 + experience_alignment: float = 0.15 + resume_quality: float = 0.10 + + +class ComponentBreakdown(BaseModel): + keyword_coverage: int + semantic_similarity: int + skills_overlap: int + experience_alignment: int + resume_quality: int + + +class SectionNote(BaseModel): + section: str + status: Literal["strong", "ok", "weak", "missing"] + note: str + score: int + + +class Recommendation(BaseModel): + priority: Literal["high", "medium", "low"] + category: str + message: str + + +class BulletRewrite(BaseModel): + original: str + improved: str + reason: str + + +# ── Full response ───────────────────────────────────────────────────────── + +class AnalyzeResponse(BaseModel): + analysis_id: str + overall_score: int + weights: ScoreWeights + components: ComponentBreakdown + matched_keywords: list[str] + missing_keywords: list[str] + weak_keywords: list[str] + section_notes: list[SectionNote] + recommendations: list[Recommendation] + bullet_rewrites: list[BulletRewrite] # populated in Phase 4 (LLM) + role_fit_verdict: str + role_fit_summary: str + parsed_sections: dict # skills list, bullets, etc. + disclaimer: str = ( + "ATS-style compatibility score (heuristic) — not an official ATS result." + ) + latency_ms: int + + +class AnalysisDetail(BaseModel): + analysis_id: str + resume_id: str + created_at: datetime + result: AnalyzeResponse diff --git a/app/schemas/resume.py b/app/schemas/resume.py new file mode 100644 index 0000000000000000000000000000000000000000..b4b0fb006b0b31ba612de68d2e85e9a16317e022 --- /dev/null +++ b/app/schemas/resume.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel + + +class ResumeUploadResponse(BaseModel): + resume_id: str + file_name: str + pages_estimate: int + extraction_status: Literal["success", "pending", "failed"] + + +class ResumeDetail(BaseModel): + resume_id: str + file_name: str + pages: int + extraction_status: Literal["success"] + uploaded_at: datetime + # Do NOT expose extracted_text in the response to keep payloads small. + # Phase 2 analysis endpoint will fetch the record server-side. + char_count: int diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/__pycache__/__init__.cpython-312.pyc b/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8496a18070f57808d55952152d00d0ed6927249 Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/services/__pycache__/ats_scoring.cpython-312.pyc b/app/services/__pycache__/ats_scoring.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80248720c7c4881bf5cedb8eeadbdbbaf037b6b3 Binary files /dev/null and b/app/services/__pycache__/ats_scoring.cpython-312.pyc differ diff --git a/app/services/__pycache__/embedding_service.cpython-312.pyc b/app/services/__pycache__/embedding_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5fea4b69edf118b8e782c9dc2d570ce0d37d81b8 Binary files /dev/null and b/app/services/__pycache__/embedding_service.cpython-312.pyc differ diff --git a/app/services/__pycache__/keyword_extractor.cpython-312.pyc b/app/services/__pycache__/keyword_extractor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2502dc0bdba9bf42dd8de3de47f13d3a4f96ca6 Binary files /dev/null and b/app/services/__pycache__/keyword_extractor.cpython-312.pyc differ diff --git a/app/services/__pycache__/llm_service.cpython-312.pyc b/app/services/__pycache__/llm_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d6d5fe7d1b619884e31a27ac00812258161787 Binary files /dev/null and b/app/services/__pycache__/llm_service.cpython-312.pyc differ diff --git a/app/services/__pycache__/pdf_extractor.cpython-312.pyc b/app/services/__pycache__/pdf_extractor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a46cb73cea1244e24be5dfe598f765bae5a8f35 Binary files /dev/null and b/app/services/__pycache__/pdf_extractor.cpython-312.pyc differ diff --git a/app/services/__pycache__/resume_parser.cpython-312.pyc b/app/services/__pycache__/resume_parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b08b550a38aa3fa01762c4c5cd38078c34f4c2f7 Binary files /dev/null and b/app/services/__pycache__/resume_parser.cpython-312.pyc differ diff --git a/app/services/ats_scoring.py b/app/services/ats_scoring.py new file mode 100644 index 0000000000000000000000000000000000000000..74b721a20349f71b7dc33064ba7e6c5e02f0b7c1 --- /dev/null +++ b/app/services/ats_scoring.py @@ -0,0 +1,248 @@ +""" +ATS scoring engine. + +Score weights (from PRD §9.2): + keyword_coverage 30% + semantic_similarity 25% + skills_overlap 20% + experience_alignment 15% + resume_quality 10% + +Each component is normalised to 0–100 before weighting. +""" +from __future__ import annotations + +import time +from dataclasses import dataclass + +from app.services.keyword_extractor import KeywordResult, extract_keywords +from app.services.resume_parser import ParsedResume, parse_resume +from app.services.embedding_service import compute_similarity + + +WEIGHTS = { + "keyword_coverage": 0.30, + "semantic_similarity": 0.25, + "skills_overlap": 0.20, + "experience_alignment": 0.15, + "resume_quality": 0.10, +} + + +@dataclass +class ComponentScores: + keyword_coverage: int # 0–100 + semantic_similarity: int # 0–100 + skills_overlap: int # 0–100 + experience_alignment: int # 0–100 + resume_quality: int # 0–100 + + def overall(self) -> int: + raw = ( + self.keyword_coverage * WEIGHTS["keyword_coverage"] + + self.semantic_similarity * WEIGHTS["semantic_similarity"] + + self.skills_overlap * WEIGHTS["skills_overlap"] + + self.experience_alignment * WEIGHTS["experience_alignment"] + + self.resume_quality * WEIGHTS["resume_quality"] + ) + return round(raw) + + +@dataclass +class SectionNote: + section: str + status: str # "strong" | "ok" | "weak" | "missing" + note: str + score: int # 0–100 heuristic + + +@dataclass +class ATSResult: + overall_score: int + components: ComponentScores + keywords: KeywordResult + parsed: ParsedResume + section_notes: list[SectionNote] + latency_ms: int + + +def run_ats_scoring( + resume_text: str, + jd_text: str, + target_role: str = "", +) -> ATSResult: + """Orchestrate the full scoring pipeline.""" + t0 = time.perf_counter() + + # ── 1. Parse ────────────────────────────────────────────────────────── + parsed = parse_resume(resume_text) + + # ── 2. Keyword extraction ───────────────────────────────────────────── + kw = extract_keywords(resume_text, jd_text) + + # ── 3. Compute component scores ─────────────────────────────────────── + components = _compute_components(parsed, kw, resume_text, jd_text) + + # ── 4. Section notes ────────────────────────────────────────────────── + section_notes = _build_section_notes(parsed, kw, components) + + overall = components.overall() + latency_ms = round((time.perf_counter() - t0) * 1000) + + return ATSResult( + overall_score=overall, + components=components, + keywords=kw, + parsed=parsed, + section_notes=section_notes, + latency_ms=latency_ms, + ) + + +# ── Component scorers ───────────────────────────────────────────────────── + + +def _compute_components( + parsed: ParsedResume, + kw: KeywordResult, + resume_text: str, + jd_text: str, +) -> ComponentScores: + + # Keyword coverage: matched / total JD keywords + total_jd = len(kw.jd_keywords) + kw_score = round((len(kw.matched) / max(total_jd, 1)) * 100) + + # Semantic similarity: cosine via embeddings (0–1 → 0–100) + sem_raw = compute_similarity(resume_text, jd_text) + sem_score = round(sem_raw * 100) + + # Skills overlap: resume skills ∩ JD keywords / JD keywords + res_skill_set = {s.lower() for s in parsed.skills_list} + jd_kw_set = {k.lower() for k in kw.jd_keywords} + overlap = len(res_skill_set & jd_kw_set) + skills_score = round((overlap / max(len(jd_kw_set), 1)) * 100) + # Boost if resume has a skills section at all + if parsed.skills_list: + skills_score = min(100, skills_score + 10) + + # Experience alignment: heuristics on bullets & action verbs + exp_score = _score_experience(parsed) + + # Resume quality: action verbs, quantification, section coverage + qual_score = _score_quality(parsed) + + return ComponentScores( + keyword_coverage=min(100, kw_score), + semantic_similarity=min(100, sem_score), + skills_overlap=min(100, skills_score), + experience_alignment=min(100, exp_score), + resume_quality=min(100, qual_score), + ) + + +def _score_experience(parsed: ParsedResume) -> int: + """Heuristic score for experience depth.""" + score = 30 # base — they have some text + if not parsed.experience_raw: + return 20 + bullets = parsed.experience_bullets + # Each bullet up to 8 is worth points + score += min(len(bullets), 8) * 4 # up to +32 + score += min(parsed.action_verb_count, 6) * 3 # up to +18 + score += min(parsed.quantified_bullet_count, 4) * 5 # up to +20 + return min(100, score) + + +def _score_quality(parsed: ParsedResume) -> int: + """Resume quality signals.""" + score = 20 + sections_present = sum([ + bool(parsed.contact), + bool(parsed.summary), + bool(parsed.skills_raw), + bool(parsed.experience_raw), + bool(parsed.projects_raw), + bool(parsed.education_raw), + bool(parsed.certifications_raw), + ]) + score += sections_present * 8 # up to +56 + score += min(parsed.action_verb_count, 3) * 4 # up to +12 + score += min(parsed.quantified_bullet_count, 3) * 4 # up to +12 + return min(100, score) + + +# ── Section notes ───────────────────────────────────────────────────────── + + +def _build_section_notes( + parsed: ParsedResume, + kw: KeywordResult, + comp: ComponentScores, +) -> list[SectionNote]: + notes: list[SectionNote] = [] + + def _add(section: str, present: bool, strong_note: str, weak_note: str, + missing_note: str, score: int) -> None: + if not present: + notes.append(SectionNote(section, "missing", missing_note, max(0, score - 30))) + elif score >= 70: + notes.append(SectionNote(section, "strong", strong_note, score)) + elif score >= 45: + notes.append(SectionNote(section, "ok", weak_note, score)) + else: + notes.append(SectionNote(section, "weak", weak_note, score)) + + _add( + "Contact", bool(parsed.contact), + "Name, email, and links detected.", + "Contact block found but may be missing phone or LinkedIn.", + "No contact information detected.", + 90 if parsed.contact else 0, + ) + _add( + "Summary", bool(parsed.summary), + "Professional summary present.", + "Summary found but consider aligning it more tightly to the role.", + "No summary or objective detected — consider adding one.", + 70 if parsed.summary else 0, + ) + skills_score = comp.skills_overlap + _add( + "Skills", bool(parsed.skills_list), + f"{len(parsed.skills_list)} skills detected; good overlap with JD.", + f"{len(parsed.skills_list)} skills detected; {len(kw.missing)} JD keywords missing.", + "No skills section detected.", + skills_score, + ) + exp_score = comp.experience_alignment + _add( + "Experience", bool(parsed.experience_raw), + f"{len(parsed.experience_bullets)} bullets; {parsed.quantified_bullet_count} quantified.", + f"{len(parsed.experience_bullets)} bullets found; add measurable outcomes.", + "No experience section detected.", + exp_score, + ) + _add( + "Projects", bool(parsed.projects_raw), + "Projects section with relevant work detected.", + "Projects present but could be better aligned to JD requirements.", + "No projects section — strongly recommended for student profiles.", + 75 if parsed.projects_raw else 0, + ) + _add( + "Education", bool(parsed.education_raw), + "Education section parsed cleanly.", + "Education detected; ensure GPA/honours included if relevant.", + "No education section detected.", + 80 if parsed.education_raw else 0, + ) + _add( + "Certifications", bool(parsed.certifications_raw), + "Certifications listed — strong ATS signal.", + "Certifications present.", + "No certifications — consider adding role-relevant ones.", + 85 if parsed.certifications_raw else 0, + ) + + return notes diff --git a/app/services/embedding_service.py b/app/services/embedding_service.py new file mode 100644 index 0000000000000000000000000000000000000000..45f2e3c7609a54652e709f655dd85e7e1e8d084d --- /dev/null +++ b/app/services/embedding_service.py @@ -0,0 +1,100 @@ +""" +Embedding service: compute semantic similarity between resume and JD. + +Strategy (two-tier): + 1. Try sentence-transformers (all-MiniLM-L6-v2) for quality embeddings. + 2. Fall back to TF-IDF cosine similarity (sklearn) if model unavailable. + +The model is loaded lazily so the server starts instantly even if the +first analysis takes a few extra seconds. +""" +from __future__ import annotations + +import logging +import threading +from typing import Any + +import numpy as np +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity + +logger = logging.getLogger(__name__) + +# ── Lazy sentence-transformers loader ──────────────────────────────────── + + +_model_lock = threading.Lock() +_model: Any = None # SentenceTransformer or None +_model_loaded = False # whether we've attempted loading + +_ST_MODEL = "all-MiniLM-L6-v2" + + +def _try_load_st_model() -> Any | None: + """Attempt to load the SentenceTransformer model once.""" + global _model, _model_loaded + with _model_lock: + if _model_loaded: + return _model + _model_loaded = True + try: + from sentence_transformers import SentenceTransformer # noqa: PLC0415 + logger.info("Loading SentenceTransformer model %s …", _ST_MODEL) + _model = SentenceTransformer(_ST_MODEL) + logger.info("SentenceTransformer model ready.") + except Exception as exc: + logger.warning("SentenceTransformer unavailable (%s); using TF-IDF fallback.", exc) + _model = None + return _model + + +# ── Public API ──────────────────────────────────────────────────────────── + + +def compute_similarity(text_a: str, text_b: str) -> float: + """ + Return a cosine similarity score in [0, 1] between two texts. + Uses sentence-transformers if available, else TF-IDF. + """ + if not text_a.strip() or not text_b.strip(): + return 0.0 + + model = _try_load_st_model() + if model is not None: + return _st_similarity(model, text_a, text_b) + return _tfidf_similarity(text_a, text_b) + + +def compute_section_similarities( + sections: dict[str, str], jd_text: str +) -> dict[str, float]: + """ + Compute per-section similarity against the JD. + Returns dict of {section_name: score_0_to_1}. + """ + results: dict[str, float] = {} + for name, content in sections.items(): + if content.strip(): + results[name] = compute_similarity(content, jd_text) + return results + + +def _st_similarity(model: Any, a: str, b: str) -> float: + embs = model.encode([a, b], convert_to_numpy=True) + score = cosine_similarity(embs[0:1], embs[1:2])[0][0] + return float(np.clip(score, 0.0, 1.0)) + + +def _tfidf_similarity(a: str, b: str) -> float: + try: + vec = TfidfVectorizer( + ngram_range=(1, 2), + stop_words="english", + max_features=8000, + ) + mat = vec.fit_transform([a, b]) + score = cosine_similarity(mat[0:1], mat[1:2])[0][0] + return float(np.clip(score, 0.0, 1.0)) + except Exception as exc: + logger.error("TF-IDF similarity failed: %s", exc) + return 0.0 diff --git a/app/services/keyword_extractor.py b/app/services/keyword_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..66340559b5bb075b423fc6fe5b9c8f714998b4e1 --- /dev/null +++ b/app/services/keyword_extractor.py @@ -0,0 +1,149 @@ +""" +Keyword extractor: curated tech keyword list + regex matching. + +Extracts: + - Which JD keywords appear in the resume (matched) + - Which JD keywords are absent from the resume (missing) + - Which matched keywords appear only weakly / once (weak) +""" +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass + + +# ── Curated tech keyword catalogue ──────────────────────────────────────── +TECH_KEYWORDS: list[str] = [ + # Languages + "Python", "JavaScript", "TypeScript", "Java", "Kotlin", "Swift", "Go", "Golang", + "Rust", "C++", "C#", "Ruby", "PHP", "Scala", "R", "MATLAB", "Bash", "Shell", + "Perl", "Dart", "Elixir", "Haskell", "Clojure", + # Web / Frontend + "React", "Next.js", "Vue", "Vue.js", "Angular", "Svelte", "HTML", "CSS", + "Tailwind", "Bootstrap", "SASS", "SCSS", "Redux", "Zustand", "GraphQL", + "REST", "REST APIs", "WebSocket", "gRPC", + # Backend Frameworks + "FastAPI", "Django", "Flask", "Express", "Node.js", "Spring Boot", "Rails", + "Laravel", "Gin", "Echo", "Fiber", "Actix", + # Databases + "PostgreSQL", "MySQL", "SQLite", "MongoDB", "Cassandra", "DynamoDB", "Redis", + "Elasticsearch", "Firestore", "Supabase", "PlanetScale", "CockroachDB", + "SQL", "NoSQL", + # Cloud & DevOps + "AWS", "GCP", "Azure", "Docker", "Kubernetes", "K8s", "Terraform", "Ansible", + "CI/CD", "GitHub Actions", "Jenkins", "CircleCI", "ArgoCD", "Helm", + "Linux", "Nginx", "Caddy", "Vercel", "Render", "Fly.io", "Railway", + # AI / ML / Data + "PyTorch", "TensorFlow", "JAX", "Keras", "scikit-learn", "sklearn", + "LangChain", "LlamaIndex", "Hugging Face", "Transformers", + "FAISS", "Chroma", "Pinecone", "Qdrant", "Weaviate", + "Embeddings", "Vector DB", "Vector Database", "RAG", "LLM", + "GPT", "OpenAI", "Gemini", "Anthropic", "Claude", + "BERT", "Sentence Transformers", "spaCy", "NLTK", + "XGBoost", "LightGBM", "CatBoost", "Random Forest", + "NLP", "Computer Vision", "CNN", "RNN", "LSTM", "Transformer", + "Reinforcement Learning", "Fine-tuning", "PEFT", "LoRA", + "MLflow", "DVC", "Weights & Biases", "wandb", + # Data Engineering + "Pandas", "NumPy", "Polars", "Spark", "PySpark", "Airflow", "Kafka", + "dbt", "Snowflake", "BigQuery", "Redshift", "Databricks", + "ETL", "Data Pipeline", + # Testing & Quality + "Pytest", "Jest", "Cypress", "Playwright", "Selenium", "JUnit", + "TDD", "BDD", "Unit Testing", "Integration Testing", + # Architecture + "Microservices", "System Design", "API Design", "Event-Driven", + "CQRS", "Event Sourcing", "Message Queue", "RabbitMQ", + # Tools & Practices + "Git", "GitHub", "GitLab", "Jira", "Agile", "Scrum", "Kanban", + "OpenAPI", "Swagger", "Postman", "Linux", +] + +# Build a lookup: lowercase → canonical name +_KEYWORD_MAP: dict[str, str] = {kw.lower(): kw for kw in TECH_KEYWORDS} + +# Also handle multi-word keywords joined differently (e.g. "ci cd" → "CI/CD") +_ALIASES: dict[str, str] = { + "ci cd": "CI/CD", + "ci/cd": "CI/CD", + "machine learning": "ML", + "deep learning": "DL", + "neural network": "DL", + "node": "Node.js", + "react.js": "React", + "nextjs": "Next.js", + "vuejs": "Vue.js", + "typescript": "TypeScript", + "javascript": "JavaScript", + "postgres": "PostgreSQL", + "mongo": "MongoDB", + "k8s": "Kubernetes", + "golang": "Go", +} + + +@dataclass +class KeywordResult: + matched: list[str] + missing: list[str] + weak: list[str] # matched but low frequency (mentioned once) + jd_keywords: list[str] # all keywords found in JD + resume_keywords: list[str] + + +def extract_keywords(resume_text: str, jd_text: str) -> KeywordResult: + """ + Find which keywords from the JD are present in the resume. + """ + jd_kws = _find_keywords(jd_text) + res_kws = _find_keywords(resume_text) + res_freq = _keyword_frequencies(resume_text) + + jd_set = set(jd_kws) + res_set = set(res_kws) + + matched = sorted(jd_set & res_set) + missing = sorted(jd_set - res_set) + + # "Weak" = matched but mentioned only once in the resume + weak = [kw for kw in matched if res_freq.get(kw, 0) <= 1] + + return KeywordResult( + matched=matched, + missing=missing, + weak=weak, + jd_keywords=sorted(jd_set), + resume_keywords=sorted(res_set), + ) + + +def _find_keywords(text: str) -> list[str]: + """Return all canonical tech keywords found in text.""" + text_lower = text.lower() + found: set[str] = set() + + # Check aliases first + for alias, canonical in _ALIASES.items(): + if alias in text_lower: + found.add(canonical) + + # Check keyword map + for kw_lower, kw_canonical in _KEYWORD_MAP.items(): + # Use word-boundary-like check (avoid matching "react" inside "reactivation") + pattern = r"(? dict[str, int]: + """Count occurrences of each keyword in text.""" + text_lower = text.lower() + freq: Counter[str] = Counter() + for kw_lower, kw_canonical in _KEYWORD_MAP.items(): + count = len(re.findall(re.escape(kw_lower), text_lower)) + if count: + freq[kw_canonical] = count + return dict(freq) diff --git a/app/services/llm_service.py b/app/services/llm_service.py new file mode 100644 index 0000000000000000000000000000000000000000..5738c57c452ca26f0e8d0a561430d6f3727a2cad --- /dev/null +++ b/app/services/llm_service.py @@ -0,0 +1,111 @@ +""" +LLM integration for rewriting weak resume bullets. +Uses Gemini to generate quantifiable, role-aligned bullet rewrites. +""" +import logging +import os +from pydantic import BaseModel +from google import genai +from google.genai import types + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class BulletRewrite(BaseModel): + original: str + improved: str + reason: str + + +class RewriteResponse(BaseModel): + bullet_rewrites: list[BulletRewrite] + + +# Lazy client initialization +_client = None + + +def _get_client() -> genai.Client | None: + global _client + if _client is not None: + return _client + + # Use config key, fallback to env var, else None + api_key = settings.gemini_api_key or os.environ.get("GEMINI_API_KEY") + if not api_key: + logger.warning("No GEMINI_API_KEY found. LLM features disabled.") + return None + + try: + _client = genai.Client(api_key=api_key) + return _client + except Exception as e: + logger.error(f"Failed to initialize Gemini client: {e}") + return None + + +def rewrite_weak_bullets( + bullets: list[str], + job_description: str, + target_role: str, + max_rewrites: int = 3 +) -> list[dict]: + """ + Selects up to `max_rewrites` weakest bullets and returns improved versions. + Returns a list of dicts: [{"original": "...", "improved": "...", "reason": "..."}] + """ + if not bullets: + return [] + + client = _get_client() + if not client: + return [] + + # Format inputs + bullets_text = "\n".join(f"- {b}" for b in bullets) + + prompt = f""" + You are an expert resume reviewer for campus placements. + + Target Role: {target_role or "Not specified"} + + Job Description (excerpt): + {job_description[:1500]} + + Candidate's Resume Bullets: + {bullets_text} + + Task: + Identify the {max_rewrites} weakest bullets (e.g., those lacking quantifiable metrics, weak action verbs, or poor alignment to the JD). + Rewrite them to be stronger, more quantifiable, and more aligned with the target role and JD. + + CONSTRAINTS (CRITICAL): + 1. DO NOT fabricate or invent any new employers, degrees, tools, or metrics. If you need a number, use placeholders like [X]% or [Number]. + 2. Enforce strong action verbs. + 3. Ensure the tone is recruiter-friendly and concise. + """ + + try: + response = client.models.generate_content( + model='gemini-2.5-flash', + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=RewriteResponse, + temperature=0.3, # Low temperature for more deterministic, grounded output + ), + ) + + # Pydantic parses the structured JSON automatically based on the response_schema + if not response.text: + return [] + + import json + data = json.loads(response.text) + return data.get("bullet_rewrites", []) + + except Exception as e: + logger.error(f"Error during bullet rewrite generation: {e}") + return [] diff --git a/app/services/pdf_extractor.py b/app/services/pdf_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..867cac2ac8957f4993665dca39272c658fac57b4 --- /dev/null +++ b/app/services/pdf_extractor.py @@ -0,0 +1,72 @@ +import re +from pathlib import Path + +import fitz # PyMuPDF + +from app.core.logging import get_logger + +logger = get_logger(__name__) + +# If extracted text is fewer than this many characters it's almost certainly +# a scanned / image-only PDF that we cannot handle in MVP. +SCANNED_THRESHOLD = 100 + + +class ExtractionError(Exception): + """Raised when the PDF cannot be meaningfully extracted.""" + + +def extract_text(path: Path) -> tuple[str, int]: + """ + Extract and clean text from a digital PDF. + + Returns + ------- + text : str + Cleaned, whitespace-normalised body text. + page_count : int + Number of pages in the document. + + Raises + ------ + ExtractionError + If the file is unreadable or appears to be a scanned/image PDF. + """ + try: + doc = fitz.open(str(path)) + except Exception as exc: + logger.error("Could not open PDF %s: %s", path, exc) + raise ExtractionError("Could not read this PDF.") from exc + + page_count = len(doc) + raw_pages: list[str] = [] + + for page in doc: + raw_pages.append(page.get_text("text")) # type: ignore[arg-type] + + doc.close() + + full_text = "\n".join(raw_pages) + cleaned = _clean(full_text) + + if len(cleaned) < SCANNED_THRESHOLD: + logger.warning( + "PDF %s yielded only %d chars — likely scanned", path, len(cleaned) + ) + raise ExtractionError( + "This looks like a scanned resume; MVP supports digital text PDFs only." + ) + + logger.info("Extracted %d chars from %d-page PDF %s", len(cleaned), page_count, path.name) + return cleaned, page_count + + +def _clean(raw: str) -> str: + """Normalise whitespace and remove common PDF artifacts.""" + # Collapse runs of whitespace that aren't newlines + text = re.sub(r"[ \t]+", " ", raw) + # Collapse 3+ consecutive blank lines into 2 + text = re.sub(r"\n{3,}", "\n\n", text) + # Strip leading/trailing whitespace per line + lines = [line.strip() for line in text.splitlines()] + return "\n".join(lines).strip() diff --git a/app/services/resume_parser.py b/app/services/resume_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..31487dbdc3d12f441855240a98821e3c1ba3ea73 --- /dev/null +++ b/app/services/resume_parser.py @@ -0,0 +1,185 @@ +""" +Resume parser: regex-based section detection + structured field extraction. + +Returns a ParsedResume dict with keys: + contact, summary, skills, experience, projects, education, certifications, raw_sections +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +# ── Section header patterns (order matters — more specific first) ────────── +_SECTION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("certifications", re.compile(r"^certif|^licen|^credential", re.I | re.M)), + ("projects", re.compile(r"^project|^portfolio|^open.?source", re.I | re.M)), + ("education", re.compile(r"^educ|^academic|^qualif|^degree", re.I | re.M)), + ("experience", re.compile(r"^(work\s+)?experience|^employment|^career|^professional\s+background", re.I | re.M)), + ("skills", re.compile(r"^(technical\s+)?skills?|^competenc|^technologies|^tech\s+stack|^tools", re.I | re.M)), + ("summary", re.compile(r"^summary|^objective|^profile|^about|^overview|^professional\s+summary", re.I | re.M)), + ("contact", re.compile(r"^contact|^personal\s+info|^details", re.I | re.M)), +] + +# Fallback: lines that look like section headers (ALL CAPS or Title-case short lines) +_HEADER_LINE = re.compile(r"^([A-Z][A-Z\s&/\-]{2,30})$") + +# Common action verbs for quality scoring +ACTION_VERBS = { + "developed", "built", "designed", "implemented", "created", "deployed", + "optimised", "optimized", "reduced", "increased", "improved", "led", + "managed", "architected", "engineered", "automated", "integrated", + "delivered", "launched", "scaled", "migrated", "refactored", "shipped", + "established", "coordinated", "analysed", "analyzed", +} + +# Quantification signals +_QUANT_RE = re.compile(r"\d+\s*(%|x|k|ms|s|mb|gb|tb|users?|requests?|hours?|days?|weeks?|months?|years?)", re.I) + + +@dataclass +class ParsedResume: + contact: str = "" + summary: str = "" + skills_raw: str = "" + experience_raw: str = "" + projects_raw: str = "" + education_raw: str = "" + certifications_raw: str = "" + skills_list: list[str] = field(default_factory=list) + experience_bullets: list[str] = field(default_factory=list) + raw_sections: dict[str, str] = field(default_factory=dict) + full_text: str = "" + + # Quality signals + action_verb_count: int = 0 + quantified_bullet_count: int = 0 + + def to_dict(self) -> dict: + return { + "contact": self.contact, + "summary": self.summary, + "skills": self.skills_list, + "experience_raw": self.experience_raw, + "experience_bullets": self.experience_bullets, + "projects_raw": self.projects_raw, + "education_raw": self.education_raw, + "certifications_raw": self.certifications_raw, + "raw_sections": self.raw_sections, + "quality": { + "action_verb_count": self.action_verb_count, + "quantified_bullet_count": self.quantified_bullet_count, + }, + } + + +def parse_resume(text: str) -> ParsedResume: + """Parse resume text into structured sections.""" + pr = ParsedResume(full_text=text) + + lines = text.splitlines() + sections = _split_into_sections(lines) + pr.raw_sections = sections + + pr.contact = sections.get("contact", _extract_contact_block(lines)) + pr.summary = sections.get("summary", "") + pr.skills_raw = sections.get("skills", "") + pr.experience_raw = sections.get("experience", "") + pr.projects_raw = sections.get("projects", "") + pr.education_raw = sections.get("education", "") + pr.certifications_raw = sections.get("certifications", "") + + pr.skills_list = _parse_skills(pr.skills_raw) + pr.experience_bullets = _extract_bullets(pr.experience_raw + "\n" + pr.projects_raw) + + # Quality signals + all_bullets = "\n".join(pr.experience_bullets) + pr.action_verb_count = sum( + 1 for b in pr.experience_bullets + if any(b.strip().lower().startswith(v) for v in ACTION_VERBS) + ) + pr.quantified_bullet_count = len(_QUANT_RE.findall(all_bullets)) + + return pr + + +# ── Private helpers ──────────────────────────────────────────────────────── + + +def _split_into_sections(lines: list[str]) -> dict[str, str]: + """ + Walk lines and assign them to labelled buckets based on header detection. + """ + sections: dict[str, list[str]] = {} + current: str | None = None + + for raw_line in lines: + line = raw_line.strip() + if not line: + if current: + sections.setdefault(current, []).append("") + continue + + label = _detect_section_label(line) + if label: + current = label + sections.setdefault(current, []) + elif current is not None: + sections[current].append(line) + # Lines before any detected section → treat as contact/top block + else: + sections.setdefault("contact", []).append(line) + + return {k: "\n".join(v).strip() for k, v in sections.items()} + + +def _detect_section_label(line: str) -> str | None: + """Return a canonical section name if this line looks like a section header.""" + clean = line.strip().rstrip(":").strip() + for name, pat in _SECTION_PATTERNS: + if pat.match(clean): + return name + # Fallback: short ALL-CAPS or Title-Case line with no punctuation + if _HEADER_LINE.match(clean) and len(clean.split()) <= 4: + return None # Don't auto-classify unknown headers + return None + + +def _extract_contact_block(lines: list[str]) -> str: + """Take the first non-empty lines as the contact/header block.""" + out: list[str] = [] + for line in lines[:15]: + stripped = line.strip() + if stripped: + out.append(stripped) + elif out: + break + return "\n".join(out) + + +def _parse_skills(skills_text: str) -> list[str]: + """Split skills section into individual skill tokens.""" + if not skills_text: + return [] + # Split on common delimiters: comma, bullet, pipe, newline, semicolon + raw = re.split(r"[,|\n•·\-–;/]+", skills_text) + skills: list[str] = [] + for item in raw: + item = item.strip() + # Skip very long items (probably a sentence, not a skill) + if item and len(item) <= 50 and len(item) >= 1: + skills.append(item) + return skills + + +def _extract_bullets(text: str) -> list[str]: + """Extract bullet-point lines from experience/projects sections.""" + bullets: list[str] = [] + for line in text.splitlines(): + stripped = line.strip() + # Lines starting with bullet char, dash, or asterisk + if stripped and (stripped[0] in "-•·*▸▪" or re.match(r"^\d+\.", stripped)): + bullet = re.sub(r"^[-•·*▸▪\d\.]+\s*", "", stripped).strip() + if len(bullet) > 20: + bullets.append(bullet) + return bullets diff --git a/app/tests/__pycache__/test_api.cpython-312-pytest-9.1.1.pyc b/app/tests/__pycache__/test_api.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d380a29073d09de04df9628e92646bb224c7756d Binary files /dev/null and b/app/tests/__pycache__/test_api.cpython-312-pytest-9.1.1.pyc differ diff --git a/app/tests/test_api.py b/app/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..883b1ddb071dd79c29434437de88ca695615068f --- /dev/null +++ b/app/tests/test_api.py @@ -0,0 +1,15 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app +from app.core.db import Base, engine + +client = TestClient(app) + +def test_health_endpoint(): + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + +def test_upload_missing_file(): + response = client.post("/api/resumes/upload", headers={"X-Session-Id": "test-session"}) + assert response.status_code == 422 # FastAPI validation error for missing form data diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b5878d3f6dc729164e06bbe98a25bf95495fa8c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +python-multipart>=0.0.9 +pymupdf>=1.24.0 +pydantic-settings>=2.3.0 +python-dotenv>=1.0.0 +scikit-learn>=1.5.0 +numpy>=1.26.0 +sentence-transformers>=3.0.0 +google-genai>=0.3.0 +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +alembic>=1.13.0