"""FastAPI backend for HR-Breaker React UI.""" import asyncio import json import os import tempfile import urllib.parse from pathlib import Path from typing import Optional, AsyncGenerator from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel # HR-Breaker imports from hr_breaker.agents import extract_name, parse_job_posting from hr_breaker.config import get_settings from hr_breaker.models import ResumeSource from hr_breaker.orchestration import optimize_for_job from hr_breaker.services import scrape_job_posting, CloudflareBlockedError from hr_breaker.services.pdf_parser import extract_text_from_pdf app = FastAPI( title="HR-Breaker API", description="AI-powered resume optimization API", version="1.0.0", ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) settings = get_settings() # Store generated PDFs temporarily TEMP_PDF_DIR = Path(tempfile.gettempdir()) / "hr_breaker_pdfs" TEMP_PDF_DIR.mkdir(exist_ok=True) class FilterResultResponse(BaseModel): filter_name: str passed: bool score: float threshold: float issues: list[str] suggestions: list[str] class ValidationResponse(BaseModel): passed: bool results: list[FilterResultResponse] class JobResponse(BaseModel): title: str company: str class OptimizeResponse(BaseModel): success: bool pdf_url: Optional[str] = None validation: Optional[ValidationResponse] = None job: Optional[JobResponse] = None error: Optional[str] = None iterations: Optional[int] = None def send_event(event_type: str, data: dict) -> str: """Format SSE event.""" return f"data: {json.dumps({'type': event_type, **data})}\n\n" @app.post("/api/stream-optimize") async def optimize_resume_stream( resume_file: Optional[UploadFile] = File(None), resume_text: Optional[str] = Form(None), job_url: Optional[str] = Form(None), job_text: Optional[str] = Form(None), max_iterations: int = Form(5), no_shame: bool = Form(False), ): """Optimize resume with streaming progress updates.""" async def generate() -> AsyncGenerator[str, None]: try: # Validate input if not resume_file and not resume_text: yield send_event("error", {"message": "Resume is required"}) return if not job_url and not job_text: yield send_event("error", {"message": "Job posting is required"}) return yield send_event("progress", {"step": "parsing_resume", "message": "Parsing resume...", "percent": 5}) # Get resume content resume_content = None if resume_file: content = await resume_file.read() if resume_file.filename.lower().endswith(".pdf"): temp_path = Path(tempfile.mktemp(suffix=".pdf")) temp_path.write_bytes(content) try: resume_content = extract_text_from_pdf(temp_path) finally: temp_path.unlink(missing_ok=True) else: resume_content = content.decode("utf-8") else: resume_content = resume_text yield send_event("progress", {"step": "extracting_name", "message": "Extracting name from resume...", "percent": 10}) # Extract name first_name, last_name = await extract_name(resume_content) name = f"{first_name or ''} {last_name or ''}".strip() or "Unknown" yield send_event("progress", {"step": "name_extracted", "message": f"Found: {name}", "percent": 15}) # Create resume source source = ResumeSource( content=resume_content, first_name=first_name, last_name=last_name, ) yield send_event("progress", {"step": "fetching_job", "message": "Fetching job posting...", "percent": 20}) # Get job posting content job_posting = None if job_url: try: job_posting = scrape_job_posting(job_url) except CloudflareBlockedError: yield send_event("error", {"message": "Bot protection detected. Please paste job description."}) return except Exception as e: yield send_event("error", {"message": f"Failed to fetch job: {str(e)}"}) return else: job_posting = job_text yield send_event("progress", {"step": "parsing_job", "message": "Analyzing job requirements...", "percent": 25}) # Parse job posting job = await parse_job_posting(job_posting) yield send_event("progress", { "step": "job_parsed", "message": f"Job: {job.title} at {job.company}", "percent": 30, "job": {"title": job.title, "company": job.company} }) yield send_event("progress", {"step": "optimizing", "message": "Starting optimization...", "percent": 35}) # Track iterations iteration_count = 0 iteration_results = [] def on_iteration(i, opt, val): nonlocal iteration_count, iteration_results iteration_count = i + 1 iteration_results.append({ "iteration": i + 1, "passed": val.passed, "results": [ { "filter_name": r.filter_name, "passed": r.passed, "score": round(r.score, 2), "threshold": r.threshold, } for r in val.results ] }) # Run optimization optimized, validation, job = await optimize_for_job( source, job_posting, max_iterations=max_iterations, on_iteration=on_iteration, job=job, parallel=True, no_shame=no_shame, ) # Send iteration updates for i, iter_result in enumerate(iteration_results): percent = 35 + int((i + 1) / max_iterations * 50) passed_count = sum(1 for r in iter_result["results"] if r["passed"]) total_count = len(iter_result["results"]) yield send_event("iteration", { "iteration": iter_result["iteration"], "passed": iter_result["passed"], "percent": percent, "message": f"Iteration {iter_result['iteration']}: {passed_count}/{total_count} filters passed", "results": iter_result["results"] }) yield send_event("progress", {"step": "generating_pdf", "message": "Generating PDF...", "percent": 90}) # Save PDF pdf_url = None if optimized and optimized.pdf_bytes: pdf_filename = f"{source.first_name or 'Resume'}_{source.last_name or ''}_{job.company}_{job.title}.pdf" pdf_filename = "".join(c for c in pdf_filename if c.isalnum() or c in "._- ") pdf_path = TEMP_PDF_DIR / pdf_filename pdf_path.write_bytes(optimized.pdf_bytes) pdf_url = f"/api/download/{urllib.parse.quote(pdf_filename)}" yield send_event("progress", {"step": "complete", "message": "Optimization complete!", "percent": 100}) # Final result yield send_event("complete", { "success": True, "pdf_url": pdf_url, "validation": { "passed": validation.passed, "results": [ { "filter_name": r.filter_name, "passed": r.passed, "score": round(r.score, 2), "threshold": r.threshold, "issues": r.issues, "suggestions": r.suggestions, } for r in validation.results ], }, "job": {"title": job.title, "company": job.company}, "iterations": iteration_count, }) except Exception as e: yield send_event("error", {"message": str(e)}) return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.post("/api/optimize", response_model=OptimizeResponse) async def optimize_resume( resume_file: Optional[UploadFile] = File(None), resume_text: Optional[str] = Form(None), job_url: Optional[str] = Form(None), job_text: Optional[str] = Form(None), max_iterations: int = Form(5), no_shame: bool = Form(False), ): """Optimize a resume for a specific job posting (non-streaming).""" # Validate input if not resume_file and not resume_text: raise HTTPException(status_code=400, detail="Either resume_file or resume_text is required") if not job_url and not job_text: raise HTTPException(status_code=400, detail="Either job_url or job_text is required") # Get resume content resume_content = None if resume_file: content = await resume_file.read() if resume_file.filename.lower().endswith(".pdf"): temp_path = Path(tempfile.mktemp(suffix=".pdf")) temp_path.write_bytes(content) try: resume_content = extract_text_from_pdf(temp_path) finally: temp_path.unlink(missing_ok=True) else: resume_content = content.decode("utf-8") else: resume_content = resume_text # Get job posting content job_posting = None if job_url: try: job_posting = scrape_job_posting(job_url) except CloudflareBlockedError: raise HTTPException( status_code=400, detail="Could not fetch job posting due to bot protection. Please paste the job description directly." ) except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch job posting: {str(e)}") else: job_posting = job_text try: # Extract name from resume first_name, last_name = await extract_name(resume_content) # Create resume source source = ResumeSource( content=resume_content, first_name=first_name, last_name=last_name, ) # Parse job posting first job = await parse_job_posting(job_posting) # Track iterations iteration_count = 0 def on_iteration(i, opt, val): nonlocal iteration_count iteration_count = i + 1 # Run optimization optimized, validation, job = await optimize_for_job( source, job_posting, max_iterations=max_iterations, on_iteration=on_iteration, job=job, parallel=True, no_shame=no_shame, ) # Save PDF pdf_url = None if optimized and optimized.pdf_bytes: pdf_filename = f"{source.first_name or 'Resume'}_{source.last_name or ''}_{job.company}_{job.title}.pdf" pdf_filename = "".join(c for c in pdf_filename if c.isalnum() or c in "._- ") pdf_path = TEMP_PDF_DIR / pdf_filename pdf_path.write_bytes(optimized.pdf_bytes) pdf_url = f"/api/download/{urllib.parse.quote(pdf_filename)}" return OptimizeResponse( success=True, pdf_url=pdf_url, validation=ValidationResponse( passed=validation.passed, results=[ FilterResultResponse( filter_name=r.filter_name, passed=r.passed, score=r.score, threshold=r.threshold, issues=r.issues, suggestions=r.suggestions, ) for r in validation.results ], ), job=JobResponse(title=job.title, company=job.company), iterations=iteration_count, ) except Exception as e: return OptimizeResponse( success=False, error=str(e), ) @app.get("/api/download/{filename:path}") async def download_pdf(filename: str): """Download a generated PDF.""" filename = urllib.parse.unquote(filename) pdf_path = TEMP_PDF_DIR / filename if not pdf_path.exists(): raise HTTPException(status_code=404, detail="PDF not found") return FileResponse( path=pdf_path, filename=filename, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "Access-Control-Expose-Headers": "Content-Disposition", }, ) @app.get("/api/preview/{filename:path}") async def preview_pdf(filename: str): """Get PDF for inline preview.""" filename = urllib.parse.unquote(filename) pdf_path = TEMP_PDF_DIR / filename if not pdf_path.exists(): raise HTTPException(status_code=404, detail="PDF not found") return FileResponse( path=pdf_path, filename=filename, media_type="application/pdf", headers={ "Content-Disposition": f'inline; filename="{filename}"', }, ) @app.get("/api/pdf-base64/{filename:path}") async def get_pdf_base64(filename: str): """Get PDF as base64 for embedding.""" import base64 filename = urllib.parse.unquote(filename) pdf_path = TEMP_PDF_DIR / filename if not pdf_path.exists(): raise HTTPException(status_code=404, detail="PDF not found") pdf_bytes = pdf_path.read_bytes() pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') return {"filename": filename, "data": pdf_base64, "size": len(pdf_bytes)} @app.get("/api/health") async def health_check(): """Health check endpoint.""" return {"status": "healthy", "api_key_configured": bool(settings.openrouter_api_key or settings.google_api_key)} # Mount static files for frontend (when built) frontend_dist = Path(__file__).parent / "frontend" / "dist" if frontend_dist.exists(): app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7860)