Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ Selected bullets are compiled via RenderCV into a professional PDF. Full provena
│   ├── app
│   │   ├── apple-icon.png
│   │   ├── compile
│   │   ├── error.tsx
│   │   ├── global-error.tsx
│   │   ├── globals.css
│   │   ├── icon.png
│   │   ├── layout.tsx
Expand Down Expand Up @@ -217,7 +219,7 @@ Selected bullets are compiled via RenderCV into a professional PDF. Full provena
│   └── vercel.json
└── package-lock.json

18 directories, 52 files
18 directories, 54 files
```

<!-- PROJECT_STRUCTURE_END -->
Expand Down
34 changes: 23 additions & 11 deletions backend/app/db/mongodb.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""MongoDB Atlas connection and database operations."""

import logging

from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError

from app.config import get_settings

logger = logging.getLogger(__name__)

_client: AsyncIOMotorClient | None = None
_db: AsyncIOMotorDatabase | None = None

Expand All @@ -14,23 +19,30 @@ async def get_database() -> AsyncIOMotorDatabase:

if _db is None:
settings = get_settings()
# Fix for macOS SSL certificate verification
mongodb_uri = settings.mongodb_uri

# Add SSL parameters to URI if not present
if "tlsAllowInvalidCertificates" not in mongodb_uri:
separator = "&" if "?" in mongodb_uri else "?"
mongodb_uri = f"{mongodb_uri}{separator}tlsAllowInvalidCertificates=true"

_client = AsyncIOMotorClient(
mongodb_uri,
tlsAllowInvalidCertificates=True, # Required for macOS Python 3.13
serverSelectionTimeoutMS=5000, # Faster timeout for debugging
)
_db = _client[settings.mongodb_database]

# Ensure indexes
await _ensure_indexes(_db)
try:
_client = AsyncIOMotorClient(
mongodb_uri,
tlsAllowInvalidCertificates=True,
serverSelectionTimeoutMS=5000,
)
_db = _client[settings.mongodb_database]
await _ensure_indexes(_db)
except ServerSelectionTimeoutError as e:
logger.error("MongoDB server selection timed out: %s", e)
_client = None
_db = None
raise RuntimeError("Could not connect to MongoDB: server selection timed out") from e
except ConnectionFailure as e:
logger.error("MongoDB connection failed: %s", e)
_client = None
_db = None
raise RuntimeError("Could not connect to MongoDB: connection failed") from e

return _db

Expand Down
80 changes: 77 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,95 @@
"""FastAPI application entry point for ResMatch."""

from fastapi import FastAPI
import logging
import re
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError
from starlette.responses import JSONResponse

from app.config import cors_origins, get_settings
from app.db.mongodb import close_database
from app.routers import job, master, resume

logger = logging.getLogger(__name__)

settings = get_settings()


@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await close_database()


app = FastAPI(
title="ResMatch API",
description="Truth-first resume tailoring engine - compiles verified experience into job-targeted resumes",
version="1.0.0",
lifespan=lifespan,
)

# CORS middleware for frontend

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
details = "; ".join(
f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()
)
return JSONResponse(
status_code=422,
content={"error": "validation_error", "detail": details},
)


@app.exception_handler(ServerSelectionTimeoutError)
async def mongo_timeout_handler(request: Request, exc: ServerSelectionTimeoutError):
logger.error("MongoDB server selection timeout: %s", exc)
return JSONResponse(
status_code=503,
content={"error": "db_unavailable", "detail": "Database is temporarily unavailable."},
)


@app.exception_handler(ConnectionFailure)
async def mongo_connection_handler(request: Request, exc: ConnectionFailure):
logger.error("MongoDB connection failure: %s", exc)
return JSONResponse(
status_code=503,
content={"error": "db_unavailable", "detail": "Database is temporarily unavailable."},
)


@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
if re.search(r"rate limit", str(exc), re.IGNORECASE):
return JSONResponse(
status_code=429,
content={"error": "rate_limit", "detail": str(exc), "retry_after": 60},
)
return JSONResponse(
status_code=500,
content={
"error": "internal_error",
"detail": "An unexpected error occurred. Please try again.",
},
)


@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled exception: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "internal_error",
"detail": "An unexpected error occurred. Please try again.",
},
)


print(f"CORS origins configured: {cors_origins}")

app.add_middleware(
Expand All @@ -25,7 +100,6 @@
allow_headers=["*"],
)

# Include routers
app.include_router(master.router, prefix="/master", tags=["Master Resume"])
app.include_router(job.router, prefix="/job", tags=["Job Description"])
app.include_router(resume.router, prefix="/resume", tags=["Resume Compilation"])
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Provenance,
ScoredUnit,
)
from app.models.errors import ErrorResponse
from app.models.job_description import JDParseRequest, ParsedJD
from app.models.master_resume import MasterResumeResponse, MasterVersion, MergeStats

Expand All @@ -29,4 +30,5 @@
"ScoredUnit",
"Provenance",
"CoverageStats",
"ErrorResponse",
]
9 changes: 9 additions & 0 deletions backend/app/models/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Standardized error response models."""

from pydantic import BaseModel


class ErrorResponse(BaseModel):
error: str
detail: str
retry_after: int | None = None
39 changes: 27 additions & 12 deletions backend/app/routers/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,48 @@ async def parse_jd(request: JDParseRequest):
if not request.url and not request.text:
raise HTTPException(status_code=400, detail="Either url or text must be provided")

result = await parse_job_description(url=request.url, text=request.text)

# Store in database
db = await get_database()
await db.parsed_jds.insert_one(result.model_dump())
try:
result = await parse_job_description(url=request.url, text=request.text)
except ValueError as e:
raise HTTPException(status_code=429, detail=str(e)) from e

try:
db = await get_database()
await db.parsed_jds.insert_one(result.model_dump())
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=503, detail="Database temporarily unavailable") from e

return result


@router.get("/{jd_id}", response_model=ParsedJD)
async def get_parsed_jd(jd_id: str):
"""Get a previously parsed job description."""
db = await get_database()
try:
db = await get_database()

doc = await db.parsed_jds.find_one({"jd_id": jd_id})
if not doc:
raise HTTPException(status_code=404, detail=f"JD {jd_id} not found")
doc = await db.parsed_jds.find_one({"jd_id": jd_id})
if not doc:
raise HTTPException(status_code=404, detail=f"JD {jd_id} not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=503, detail="Database temporarily unavailable") from e

return ParsedJD(**doc)


@router.get("/", response_model=list[ParsedJD])
async def list_parsed_jds(limit: int = 20):
"""List recently parsed job descriptions."""
db = await get_database()
try:
db = await get_database()

cursor = db.parsed_jds.find().sort("created_at", -1).limit(limit)
jds = [ParsedJD(**doc) async for doc in cursor]
cursor = db.parsed_jds.find().sort("created_at", -1).limit(limit)
jds = [ParsedJD(**doc) async for doc in cursor]
except Exception as e:
raise HTTPException(status_code=503, detail="Database temporarily unavailable") from e

return jds
Loading