"""CV Application - Main FastAPI Server.""" import json import os import uuid import subprocess import asyncio from datetime import date, datetime from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pathlib import Path import database as db from database import Json from config import get_settings from doc_parser import extract_text import ai_service as ai settings = get_settings() app = FastAPI(title="CV Application", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Ensure upload directory exists os.makedirs(settings.upload_dir, exist_ok=True) # Serve static files static_dir = Path(__file__).parent / "static" static_dir.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") INITIALIZED = False @app.on_event("startup") async def startup(): global INITIALIZED db.init_db() INITIALIZED = True # ============================================================ # CANDIDATES / CV UPLOAD # ============================================================ @app.post("/api/candidates/upload") async def upload_cv(file: UploadFile = File(...)): """Upload a CV document, extract text, store it, and trigger AI parsing.""" # Save file file_ext = Path(file.filename).suffix saved_name = f"{uuid.uuid4()}{file_ext}" file_path = os.path.join(settings.upload_dir, saved_name) with open(file_path, "wb") as f: content = await file.read() f.write(content) # Extract text try: raw_text = extract_text(file_path) except Exception as e: raw_text = f"[Extraction error: {str(e)}]" if not raw_text or len(raw_text.strip()) < 10: raw_text = "[No text could be extracted from this document]" # Create candidate record with raw text result = db.execute(""" INSERT INTO candidates (raw_cv_text, source_filename, parse_status) VALUES (%s, %s, 'pending') RETURNING id """, (raw_text, file.filename)) candidate_id = str(result["id"]) # Trigger AI parsing (synchronous for now) try: parsed = ai.parse_cv(raw_text) # Update candidate record db.execute(""" UPDATE candidates SET first_name = %s, last_name = %s, email = %s, phone = %s, address = %s, linkedin = %s, github = %s, website = %s, summary = %s, parse_status = 'parsed', updated_at = NOW() WHERE id = %s """, ( parsed.get("first_name", ""), parsed.get("last_name", ""), parsed.get("email", ""), parsed.get("phone", ""), parsed.get("address", ""), parsed.get("linkedin", ""), parsed.get("github", ""), parsed.get("website", ""), parsed.get("summary", ""), candidate_id )) # Insert skills for skill in parsed.get("skills", []): start_d = _parse_date(skill.get("start_date")) end_d = _parse_date(skill.get("end_date")) db.execute(""" INSERT INTO skills (candidate_id, skill_name, skill_category, proficiency, start_date, end_date) VALUES (%s, %s, %s, %s, %s, %s) """, (candidate_id, skill.get("skill_name", ""), skill.get("skill_category", ""), skill.get("proficiency", ""), start_d, end_d)) # Insert experience for exp in parsed.get("experience", []): start_d = _parse_date(exp.get("start_date")) end_d = _parse_date(exp.get("end_date")) db.execute(""" INSERT INTO experience (candidate_id, company, position, location, start_date, end_date, description, achievements, skills_used) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (candidate_id, exp.get("company", ""), exp.get("position", ""), exp.get("location", ""), start_d, end_d, exp.get("description", ""), json.dumps(exp.get("achievements", [])), json.dumps(exp.get("skills_used", [])))) # Insert education for edu in parsed.get("education", []): start_d = _parse_date(edu.get("start_date")) end_d = _parse_date(edu.get("end_date")) db.execute(""" INSERT INTO education (candidate_id, institution, degree, field_of_study, start_date, end_date, grade, description) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, (candidate_id, edu.get("institution", ""), edu.get("degree", ""), edu.get("field_of_study", ""), start_d, end_d, edu.get("grade", ""), edu.get("description", ""))) # Insert certifications for cert in parsed.get("certifications", []): issue_d = _parse_date(cert.get("issue_date")) expiry_d = _parse_date(cert.get("expiry_date")) db.execute(""" INSERT INTO certifications (candidate_id, name, issuer, issue_date, expiry_date, credential_id) VALUES (%s, %s, %s, %s, %s, %s) """, (candidate_id, cert.get("name", ""), cert.get("issuer", ""), issue_d, expiry_d, cert.get("credential_id", ""))) return {"status": "parsed", "candidate_id": candidate_id, "parsed_data": parsed} except Exception as e: db.execute(""" UPDATE candidates SET parse_status = 'error', parse_error = %s WHERE id = %s """, (str(e), candidate_id)) return {"status": "error", "candidate_id": candidate_id, "error": str(e)} @app.get("/api/candidates") async def list_candidates(search: str = None, page: int = 1, limit: int = 20): """List all candidates with pagination.""" offset = (page - 1) * limit params = [limit, offset] where_clause = "" if search: where_clause = "WHERE (first_name ILIKE %s OR last_name ILIKE %s OR email ILIKE %s OR summary ILIKE %s)" search_param = f"%{search}%" params = [search_param, search_param, search_param, search_param, limit, offset] count_row = db.query(f"SELECT COUNT(*) as total FROM candidates {where_clause}", [f"%{search}%"] * 4 if search else None, fetch='one') rows = db.query(f""" SELECT id, first_name, last_name, email, phone, parse_status, created_at, LEFT(raw_cv_text, 200) as preview FROM candidates {where_clause} ORDER BY created_at DESC LIMIT %s OFFSET %s """, params, fetch='all') return {"candidates": [dict(r) for r in rows], "total": count_row["total"] if count_row else 0, "page": page, "limit": limit} @app.get("/api/candidates/{candidate_id}") async def get_candidate(candidate_id: str): """Get full candidate data including skills, experience, education, certifications.""" candidate = db.query("SELECT * FROM candidates WHERE id = %s", (candidate_id,), fetch='one') if not candidate: raise HTTPException(404, "Candidate not found") skills = db.query("SELECT * FROM skills WHERE candidate_id = %s ORDER BY skill_category, skill_name", (candidate_id,), fetch='all') experience = db.query("SELECT * FROM experience WHERE candidate_id = %s ORDER BY start_date DESC", (candidate_id,), fetch='all') education = db.query("SELECT * FROM education WHERE candidate_id = %s ORDER BY start_date DESC", (candidate_id,), fetch='all') certs = db.query("SELECT * FROM certifications WHERE candidate_id = %s ORDER BY issue_date DESC", (candidate_id,), fetch='all') # Calculate dynamic skill years as of today today = date.today() skills_with_years = [] for s in skills: s_dict = dict(s) s_dict["years_experience"] = ai.calculate_years_experience( s["start_date"], s["end_date"], today ) skills_with_years.append(s_dict) return { "candidate": dict(candidate), "skills": skills_with_years, "experience": [dict(e) for e in experience], "education": [dict(e) for e in education], "certifications": [dict(c) for c in certs] } @app.delete("/api/candidates/{candidate_id}") async def delete_candidate(candidate_id: str): """Delete a candidate and all related data.""" db.execute("DELETE FROM candidates WHERE id = %s", (candidate_id,)) return {"status": "deleted"} @app.put("/api/candidates/{candidate_id}") async def update_candidate(candidate_id: str, request: Request): """Update candidate fields.""" data = await request.json() fields = ["first_name", "last_name", "email", "phone", "address", "linkedin", "github", "website", "summary"] updates = [] params = [] for f in fields: if f in data: updates.append(f"{f} = %s") params.append(data[f]) if updates: updates.append("updated_at = NOW()") params.append(candidate_id) db.execute(f"UPDATE candidates SET {', '.join(updates)} WHERE id = %s", params) return {"status": "updated"} # ============================================================ # SKILLS / EXPERIENCE / EDUCATION CRUD # ============================================================ @app.post("/api/candidates/{candidate_id}/skills") async def add_skill(candidate_id: str, request: Request): data = await request.json() result = db.execute(""" INSERT INTO skills (candidate_id, skill_name, skill_category, proficiency, start_date, end_date) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, (candidate_id, data.get("skill_name", ""), data.get("skill_category", ""), data.get("proficiency", ""), _parse_date(data.get("start_date")), _parse_date(data.get("end_date")))) return {"id": str(result["id"]), "status": "created"} @app.put("/api/skills/{skill_id}") async def update_skill(skill_id: str, request: Request): data = await request.json() fields = ["skill_name", "skill_category", "proficiency", "start_date", "end_date"] updates = [] params = [] for f in fields: if f in data: updates.append(f"{f} = %s") params.append(_parse_date(data[f]) if "date" in f else data[f]) if updates: params.append(skill_id) db.execute(f"UPDATE skills SET {', '.join(updates)} WHERE id = %s", params) return {"status": "updated"} @app.delete("/api/skills/{skill_id}") async def delete_skill(skill_id: str): db.execute("DELETE FROM skills WHERE id = %s", (skill_id,)) return {"status": "deleted"} @app.post("/api/candidates/{candidate_id}/experience") async def add_experience(candidate_id: str, request: Request): data = await request.json() result = db.execute(""" INSERT INTO experience (candidate_id, company, position, location, start_date, end_date, description, achievements, skills_used) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (candidate_id, data.get("company", ""), data.get("position", ""), data.get("location", ""), _parse_date(data.get("start_date")), _parse_date(data.get("end_date")), data.get("description", ""), json.dumps(data.get("achievements", [])), json.dumps(data.get("skills_used", [])))) return {"id": str(result["id"]), "status": "created"} @app.delete("/api/experience/{exp_id}") async def delete_experience(exp_id: str): db.execute("DELETE FROM experience WHERE id = %s", (exp_id,)) return {"status": "deleted"} # ============================================================ # TEMPLATES # ============================================================ @app.get("/api/templates") async def list_templates(): rows = db.query("SELECT * FROM cv_templates ORDER BY created_at DESC", fetch='all') return {"templates": [dict(r) for r in rows]} @app.get("/api/templates/{template_id}") async def get_template(template_id: str): row = db.query("SELECT * FROM cv_templates WHERE id = %s", (template_id,), fetch='one') if not row: raise HTTPException(404, "Template not found") return dict(row) @app.post("/api/templates") async def create_template(request: Request): data = await request.json() result = db.execute(""" INSERT INTO cv_templates (name, description, template_structure, styling, created_by, generation_prompt) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, (data.get("name", ""), data.get("description", ""), json.dumps(data.get("template_structure", {})), data.get("styling", ""), data.get("created_by", "manual"), data.get("generation_prompt", ""))) return {"id": str(result["id"]), "status": "created"} @app.post("/api/templates/generate") async def generate_template_ai(request: Request): """Generate a template using AI from a text description.""" data = await request.json() description = data.get("description", "") if not description: raise HTTPException(400, "Description required") template_structure = ai.generate_template(description) result = db.execute(""" INSERT INTO cv_templates (name, description, template_structure, styling, created_by, generation_prompt) VALUES (%s, %s, %s, %s, 'ai', %s) RETURNING id """, (data.get("name", "AI Generated Template"), description, json.dumps(template_structure.get("sections", template_structure)), template_structure.get("styling", ""), description)) return {"id": str(result["id"]), "template_structure": template_structure, "status": "generated"} @app.put("/api/templates/{template_id}") async def update_template(template_id: str, request: Request): data = await request.json() result = db.execute(""" UPDATE cv_templates SET name = %s, description = %s, template_structure = %s, styling = %s, updated_at = NOW() WHERE id = %s RETURNING id """, (data.get("name", ""), data.get("description", ""), json.dumps(data.get("template_structure", {})), data.get("styling", ""), template_id)) return {"status": "updated"} @app.delete("/api/templates/{template_id}") async def delete_template(template_id: str): db.execute("DELETE FROM cv_templates WHERE id = %s", (template_id,)) return {"status": "deleted"} # ============================================================ # REQUIREMENT REQUESTS # ============================================================ @app.get("/api/requirements") async def list_requirements(): rows = db.query("SELECT * FROM requirement_requests ORDER BY created_at DESC", fetch='all') return {"requirements": [dict(r) for r in rows]} @app.get("/api/requirements/{req_id}") async def get_requirement(req_id: str): row = db.query("SELECT * FROM requirement_requests WHERE id = %s", (req_id,), fetch='one') if not row: raise HTTPException(404, "Requirement not found") # Also get generated CVs for this requirement gen_cvs = db.query("SELECT * FROM generated_cvs WHERE requirement_request_id = %s ORDER BY created_at DESC", (req_id,), fetch='all') return {**dict(row), "generated_cvs": [dict(g) for g in gen_cvs]} @app.post("/api/requirements") async def create_requirement(request: Request): data = await request.json() result = db.execute(""" INSERT INTO requirement_requests (title, description, customer_name, requirements) VALUES (%s, %s, %s, %s) RETURNING id """, (data.get("title", ""), data.get("description", ""), data.get("customer_name", ""), json.dumps(data.get("requirements", [])))) return {"id": str(result["id"]), "status": "created"} @app.put("/api/requirements/{req_id}") async def update_requirement(req_id: str, request: Request): data = await request.json() db.execute(""" UPDATE requirement_requests SET title = %s, description = %s, customer_name = %s, requirements = %s, updated_at = NOW() WHERE id = %s """, (data.get("title", ""), data.get("description", ""), data.get("customer_name", ""), json.dumps(data.get("requirements", [])), req_id)) return {"status": "updated"} @app.delete("/api/requirements/{req_id}") async def delete_requirement(req_id: str): db.execute("DELETE FROM requirement_requests WHERE id = %s", (req_id,)) return {"status": "deleted"} # ============================================================ # MATCHING + CV GENERATION # ============================================================ @app.post("/api/requirements/{req_id}/match") async def match_candidates(req_id: str, request: Request): """Match all candidates against the requirements. Returns matches with scores.""" req = db.query("SELECT * FROM requirement_requests WHERE id = %s", (req_id,), fetch='one') if not req: raise HTTPException(404, "Requirement not found") requirements = req["requirements"] if isinstance(req["requirements"], list) else json.loads(req["requirements"]) # Get all candidates candidates = db.query("SELECT id, first_name, last_name FROM candidates WHERE parse_status = 'parsed'", fetch='all') results = [] for cand in candidates: # Get full candidate data full_data = await get_candidate(str(cand["id"])) try: matches = ai.match_candidate_to_requirements(full_data, requirements) for m in matches: m["candidate_id"] = str(cand["id"]) m["candidate_name"] = f"{cand['first_name']} {cand['last_name']}".strip() results.append(m) except Exception as e: results.append({ "candidate_id": str(cand["id"]), "candidate_name": f"{cand['first_name']} {cand['last_name']}".strip(), "error": str(e) }) # Sort by match_score descending results.sort(key=lambda x: x.get("match_score", 0), reverse=True) return {"matches": results} @app.post("/api/requirements/{req_id}/generate") async def generate_cvs_for_requirement(req_id: str, request: Request): """Generate tailored CVs for candidates that match the requirement. Body: { "candidate_ids": ["uuid1", "uuid2"], // optional, if empty uses all parsed candidates "template_id": "uuid", // optional template "position_title": "specific position" // optional, filter to one position } """ data = await request.json() req = db.query("SELECT * FROM requirement_requests WHERE id = %s", (req_id,), fetch='one') if not req: raise HTTPException(404, "Requirement not found") requirements = req["requirements"] if isinstance(req["requirements"], list) else json.loads(req["requirements"]) # Get template if specified template_structure = {"sections": []} if data.get("template_id"): tmpl = db.query("SELECT * FROM cv_templates WHERE id = %s", (data["template_id"],), fetch='one') if tmpl: template_structure = tmpl["template_structure"] if isinstance(tmpl["template_structure"], dict) else json.loads(tmpl["template_structure"]) # Get candidates candidate_ids = data.get("candidate_ids", []) if not candidate_ids: # Use all parsed candidates (could be limited by match score threshold) candidates = db.query("SELECT id FROM candidates WHERE parse_status = 'parsed'", fetch='all') candidate_ids = [str(c["id"]) for c in candidates] # Filter to specific position if requested if data.get("position_title"): requirements = [r for r in requirements if r.get("position_title") == data["position_title"]] gen_date = date.today() generated = [] for cand_id in candidate_ids: full_data = await get_candidate(cand_id) for req_item in requirements: try: result = ai.generate_aligned_cv(full_data, req_item, template_structure, gen_date) gen_row = db.execute(""" INSERT INTO generated_cvs (candidate_id, requirement_request_id, template_id, position_title, generated_content, generated_data, generation_date, match_score, match_reasoning, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft') RETURNING id """, (cand_id, req_id, data.get("template_id"), req_item.get("position_title", ""), result.get("generated_content", ""), json.dumps(result.get("generated_data", {})), gen_date, result.get("match_score", 0), result.get("changes_made", ""))) generated.append({ "id": str(gen_row["id"]), "candidate_id": cand_id, "candidate_name": f"{full_data['candidate'].get('first_name','')} {full_data['candidate'].get('last_name','')}".strip(), "position_title": req_item.get("position_title", ""), "status": "draft" }) except Exception as e: generated.append({ "candidate_id": cand_id, "position_title": req_item.get("position_title", ""), "error": str(e) }) return {"generated": generated, "generation_date": gen_date.isoformat()} # ============================================================ # GENERATED CVs # ============================================================ @app.get("/api/generated-cvs") async def list_generated_cvs(req_id: str = None, candidate_id: str = None): where_parts = [] params = [] if req_id: where_parts.append("requirement_request_id = %s") params.append(req_id) if candidate_id: where_parts.append("candidate_id = %s") params.append(candidate_id) where_clause = "WHERE " + " AND ".join(where_parts) if where_parts else "" rows = db.query(f""" SELECT gc.*, c.first_name, c.last_name FROM generated_cvs gc LEFT JOIN candidates c ON gc.candidate_id = c.id {where_clause} ORDER BY gc.created_at DESC """, params if params else None, fetch='all') return {"generated_cvs": [dict(r) for r in rows]} @app.get("/api/generated-cvs/{gen_id}") async def get_generated_cv(gen_id: str): row = db.query(""" SELECT gc.*, c.first_name, c.last_name, c.email FROM generated_cvs gc LEFT JOIN candidates c ON gc.candidate_id = c.id WHERE gc.id = %s """, (gen_id,), fetch='one') if not row: raise HTTPException(404, "Generated CV not found") return dict(row) @app.put("/api/generated-cvs/{gen_id}") async def update_generated_cv(gen_id: str, request: Request): """Update generated CV content (user edits).""" data = await request.json() db.execute(""" UPDATE generated_cvs SET edited_content = %s, edited_at = NOW(), status = %s, updated_at = NOW() WHERE id = %s """, (data.get("edited_content", data.get("generated_content", "")), data.get("status", "draft"), gen_id)) return {"status": "updated"} @app.delete("/api/generated-cvs/{gen_id}") async def delete_generated_cv(gen_id: str): db.execute("DELETE FROM generated_cvs WHERE id = %s", (gen_id,)) return {"status": "deleted"} # ============================================================ # CHAT # ============================================================ @app.post("/api/chat") async def chat_with_ai(request: Request): """Chat with the AI assistant. Body: {message, conversation_id?, context?}""" data = await request.json() message = data.get("message", "") conversation_id = data.get("conversation_id") context = data.get("context", "") # Get conversation history history = [] if conversation_id: msgs = db.query(""" SELECT role, content FROM chat_messages WHERE conversation_id = %s ORDER BY created_at ASC LIMIT 20 """, (conversation_id,), fetch='all') history = [dict(m) for m in msgs] else: # Create new conversation conv = db.execute(""" INSERT INTO chat_conversations (context_type, title) VALUES (%s, %s) RETURNING id """, (data.get("context_type", "general"), message[:50])) conversation_id = str(conv["id"]) # Save user message db.execute(""" INSERT INTO chat_messages (conversation_id, role, content) VALUES (%s, 'user', %s) """, (conversation_id, message)) # Get AI response response = ai.chat(message, history, context) # Save AI response db.execute(""" INSERT INTO chat_messages (conversation_id, role, content) VALUES (%s, 'assistant', %s) """, (conversation_id, response)) return {"response": response, "conversation_id": conversation_id} @app.get("/api/chat/conversations") async def list_conversations(): rows = db.query(""" SELECT c.*, (SELECT content FROM chat_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message FROM chat_conversations ORDER BY updated_at DESC """, fetch='all') return {"conversations": [dict(r) for r in rows]} @app.get("/api/chat/conversations/{conv_id}") async def get_conversation(conv_id: str): msgs = db.query(""" SELECT * FROM chat_messages WHERE conversation_id = %s ORDER BY created_at ASC """, (conv_id,), fetch='all') return {"messages": [dict(m) for m in msgs]} # ============================================================ # DASHBOARD STATS # ============================================================ @app.get("/api/stats") async def get_stats(): candidates = db.query("SELECT COUNT(*) as count FROM candidates", fetch='one') parsed = db.query("SELECT COUNT(*) as count FROM candidates WHERE parse_status = 'parsed'", fetch='one') templates = db.query("SELECT COUNT(*) as count FROM cv_templates", fetch='one') requirements = db.query("SELECT COUNT(*) as count FROM requirement_requests", fetch='one') generated = db.query("SELECT COUNT(*) as count FROM generated_cvs", fetch='one') skills_count = db.query("SELECT COUNT(DISTINCT skill_name) as count FROM skills", fetch='one') return { "total_candidates": candidates["count"] if candidates else 0, "parsed_candidates": parsed["count"] if parsed else 0, "total_templates": templates["count"] if templates else 0, "total_requirements": requirements["count"] if requirements else 0, "total_generated_cvs": generated["count"] if generated else 0, "unique_skills": skills_count["count"] if skills_count else 0 } # ============================================================ # UTILITY # ============================================================ def _parse_date(d): """Parse a date string or return None.""" if d is None or d == "" or d == "null": return None if isinstance(d, date): return d try: return datetime.strptime(d, "%Y-%m-%d").date() except (ValueError, TypeError): try: return datetime.strptime(d, "%Y-%m-%dT%H:%M:%S").date() except (ValueError, TypeError): try: # Try just year return datetime.strptime(d, "%Y").date() except (ValueError, TypeError): return None # ============================================================ # PDF RENDERING (Puppeteer) # ============================================================ @app.post("/api/render-pdf") async def render_pdf(request: Request): """Render a CV template + candidate data to PDF using the Puppeteer renderer. Body: { "template": {JSON CV Template schema}, "cv_data": {candidate data}, "generation_date": "YYYY-MM-DD" (optional) } Returns: {"pdf_url": "/api/pdf/", "html_url": "/api/html/"} """ data = await request.json() template = data.get("template", {}) cv_data = data.get("cv_data", {}) gen_date = data.get("generation_date", date.today().isoformat()) return await _do_render_pdf(template, cv_data, gen_date) async def _do_render_pdf(template, cv_data, gen_date): """Core PDF rendering logic — calls the Node Puppeteer renderer.""" output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output") os.makedirs(output_dir, exist_ok=True) template_path = os.path.join(output_dir, f"template_{uuid.uuid4().hex[:8]}.json") data_path = os.path.join(output_dir, f"cvdata_{uuid.uuid4().hex[:8]}.json") with open(template_path, "w") as f: json.dump(template, f) with open(data_path, "w") as f: json.dump(cv_data, f, default=str) # Create a Node script that calls the renderer script = f""" const {{ renderToPDF }} = require('/root/workspace/cv-app/renderer/render.js'); const template = require('{template_path}'); const cvData = require('{data_path}'); renderToPDF(template, cvData, {{ generationDate: '{gen_date}' }}) .then(r => console.log(JSON.stringify({{ htmlPath: r.htmlPath, pdfPath: r.pdfPath }}))) .catch(e => {{ console.error(e.message); process.exit(1); }}); """ script_path = os.path.join(output_dir, f"render_{uuid.uuid4().hex[:8]}.js") with open(script_path, "w") as f: f.write(script) try: result = subprocess.run( ["node", script_path], capture_output=True, text=True, timeout=60, cwd="/root/workspace/cv-app/renderer" ) if result.returncode != 0: raise HTTPException(500, f"Render failed: {result.stderr[:500]}") output = json.loads(result.stdout.strip()) pdf_filename = os.path.basename(output["pdfPath"]) html_filename = os.path.basename(output["htmlPath"]) return { "pdf_url": f"/api/pdf/{pdf_filename}", "html_url": f"/api/html/{html_filename}", "pdf_path": output["pdfPath"] } except subprocess.TimeoutExpired: raise HTTPException(500, "PDF render timed out") finally: # Clean up temp files for p in [template_path, data_path, script_path]: try: os.unlink(p) except: pass @app.get("/api/pdf/{filename}") async def serve_pdf(filename: str): """Serve a generated PDF file.""" pdf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output", filename) if not os.path.exists(pdf_path): raise HTTPException(404, "PDF not found") return FileResponse(pdf_path, media_type="application/pdf", filename=filename) @app.get("/api/html/{filename}") async def serve_html(filename: str): """Serve a generated HTML file.""" html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output", filename) if not os.path.exists(html_path): raise HTTPException(404, "HTML not found") return FileResponse(html_path, media_type="text/html") @app.post("/api/generated-cvs/{gen_id}/render-pdf") async def render_generated_cv_pdf(gen_id: str): """Render an existing generated CV to PDF using its stored template + data.""" row = db.query(""" SELECT gc.*, c.first_name, c.last_name FROM generated_cvs gc LEFT JOIN candidates c ON gc.candidate_id = c.id WHERE gc.id = %s """, (gen_id,), fetch='one') if not row: raise HTTPException(404, "Generated CV not found") # Get the template if one was used template_schema = {"canvas": {"width": 794, "height": 1123, "columns": 12}, "blocks": []} if row.get("template_id"): tmpl = db.query("SELECT * FROM cv_templates WHERE id = %s", (str(row["template_id"]),), fetch='one') if tmpl: tstruct = tmpl["template_structure"] if isinstance(tstruct, str): tstruct = json.loads(tstruct) template_schema = tstruct # If no blocks in template, build a default layout if not template_schema.get("blocks"): template_schema["blocks"] = [ {"blockId": "header", "type": "PersonalDetails", "title": "Header", "x": 0, "y": 0, "w": 12, "h": 8}, {"blockId": "summary", "type": "ProfessionalSummary", "title": "Summary", "x": 0, "y": 8, "w": 12, "h": 5}, {"blockId": "experience", "type": "WorkExperience", "title": "Experience", "x": 0, "y": 13, "w": 8, "h": 40, "config": {"showAchievements": True}}, {"blockId": "skills", "type": "SkillsList", "title": "Skills", "x": 8, "y": 13, "w": 4, "h": 25, "config": {"groupByCategory": True, "showYears": True, "sidebar": True}}, {"blockId": "education", "type": "Education", "title": "Education", "x": 8, "y": 38, "w": 4, "h": 15}, {"blockId": "footer", "type": "Footer", "title": "Footer", "x": 0, "y": 105, "w": 12, "h": 3, "config": {"content": f"Generated {row['generation_date']}"}} ] # Get full candidate data full_data = await get_candidate(str(row["candidate_id"])) # Call the render logic directly return await _do_render_pdf(template_schema, full_data, str(row["generation_date"])) # ============================================================ # CV BATCHES # ============================================================ @app.get("/api/batches") async def list_batches(): """List all CV batches.""" rows = db.query("SELECT * FROM cv_batches ORDER BY created_at DESC") return {"batches": [dict(r) for r in rows]} @app.post("/api/batches") async def create_batch(request: Request): """Create a new batch manually (name + description only).""" body = await request.json() name = body.get("name", "").strip() if not name: raise HTTPException(400, "Batch name is required") row = db.execute( "INSERT INTO cv_batches (name, description) VALUES (%s, %s) RETURNING *", (name, body.get("description", "")) ) return dict(row) @app.post("/api/batches/upload") async def upload_batch_document(file: UploadFile = File(...)): """Upload a requirements document, extract positions with AI, create a batch.""" # Extract text from the document content = await file.read() import tempfile with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{file.filename}") as tmp: tmp.write(content) tmp_path = tmp.name try: doc_text = extract_text(tmp_path) finally: os.unlink(tmp_path) if not doc_text or len(doc_text.strip()) < 50: raise HTTPException(400, "Could not extract enough text from the document") # AI extracts requirements try: result = ai.extract_requirements(doc_text) except Exception as e: # If AI fails, create the batch with just the raw text result = {"batch_name": file.filename.replace(".pdf", "").replace(".docx", ""), "description": "AI extraction failed — edit manually", "positions": []} batch_name = result.get("batch_name", file.filename) description = result.get("description", "") positions = result.get("positions", []) row = db.execute( "INSERT INTO cv_batches (name, description, requirements_text, positions) VALUES (%s, %s, %s, %s) RETURNING *", (batch_name, description, doc_text, Json(positions)) ) return dict(row) @app.get("/api/batches/{batch_id}") async def get_batch(batch_id: str): """Get a single batch with its items.""" batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one') if not batch: raise HTTPException(404, "Batch not found") items = db.query(""" SELECT bi.*, c.first_name, c.last_name, c.email FROM batch_items bi JOIN candidates c ON bi.candidate_id = c.id WHERE bi.batch_id = %s ORDER BY bi.position_title, bi.match_score DESC """, (batch_id,)) chat = db.query("SELECT * FROM batch_chat WHERE batch_id = %s ORDER BY created_at ASC", (batch_id,)) return { "batch": dict(batch), "items": [dict(r) for r in items], "chat": [dict(r) for r in chat] } @app.put("/api/batches/{batch_id}") async def update_batch(batch_id: str, request: Request): """Update batch name/description/template.""" body = await request.json() row = db.execute( """UPDATE cv_batches SET name = %s, description = %s, template_id = %s, updated_at = NOW() WHERE id = %s RETURNING *""", (body.get("name"), body.get("description"), body.get("template_id"), batch_id) ) if not row: raise HTTPException(404, "Batch not found") return dict(row) @app.delete("/api/batches/{batch_id}") async def delete_batch(batch_id: str): """Delete a batch (cascades to items and chat).""" db.execute("DELETE FROM cv_batches WHERE id = %s", (batch_id,)) return {"success": True} @app.post("/api/batches/{batch_id}/analyze") async def analyze_batch(batch_id: str): """Run AI matching against all candidates for each position in the batch.""" batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one') if not batch: raise HTTPException(404, "Batch not found") positions = batch["positions"] or [] if not positions: raise HTTPException(400, "No positions defined in this batch") # Fetch all candidates with their full data candidates_raw = db.query("SELECT * FROM candidates ORDER BY created_at DESC") candidates = [] for c in candidates_raw: c_dict = dict(c) c_dict["skills"] = [dict(s) for s in db.query("SELECT * FROM skills WHERE candidate_id = %s", (c["id"],))] c_dict["experience"] = [dict(e) for e in db.query("SELECT * FROM experience WHERE candidate_id = %s", (c["id"],))] c_dict["education"] = [dict(e) for e in db.query("SELECT * FROM education WHERE candidate_id = %s", (c["id"],))] c_dict["certifications"] = [dict(cert) for cert in db.query("SELECT * FROM certifications WHERE candidate_id = %s", (c["id"],))] candidates.append(c_dict) if not candidates: raise HTTPException(400, "No candidates in the database to match against") # Clear existing proposed items for this batch db.execute("DELETE FROM batch_items WHERE batch_id = %s AND status = 'proposed'", (batch_id,)) total_matched = 0 for position in positions: try: matches = ai.match_candidates_for_position(position, candidates) except Exception as e: print(f"Match error for position {position.get('job_title', '?')}: {e}") continue for match in matches: candidate_id = match.get("candidate_id") if not candidate_id: continue # Verify candidate exists exists = db.query("SELECT 1 FROM candidates WHERE id = %s", (candidate_id,), fetch='one') if not exists: continue db.execute( """INSERT INTO batch_items (batch_id, candidate_id, position_title, match_score, match_reasoning, realigned_cv_data, status) VALUES (%s, %s, %s, %s, %s, %s, 'proposed')""", (batch_id, candidate_id, position.get("job_title", ""), match.get("match_score", 0), match.get("reasoning", ""), Json({"realignment_suggestion": match.get("realignment_suggestion", "")})) ) total_matched += 1 # Update batch status db.execute("UPDATE cv_batches SET status = 'active', updated_at = NOW() WHERE id = %s", (batch_id,)) return {"success": True, "matched": total_matched} @app.put("/api/batches/{batch_id}/items/{item_id}") async def update_batch_item(batch_id: str, item_id: str, request: Request): """Update a batch item (approve, remove, edit realignment).""" body = await request.json() row = db.execute( """UPDATE batch_items SET status = %s, realigned_cv_data = %s, updated_at = NOW() WHERE id = %s AND batch_id = %s RETURNING *""", (body.get("status", "proposed"), Json(body.get("realigned_cv_data")), item_id, batch_id) ) if not row: raise HTTPException(404, "Batch item not found") return dict(row) @app.delete("/api/batches/{batch_id}/items/{item_id}") async def delete_batch_item(batch_id: str, item_id: str): """Delete a batch item.""" db.execute("DELETE FROM batch_items WHERE id = %s AND batch_id = %s", (item_id, batch_id)) return {"success": True} @app.post("/api/batches/{batch_id}/chat") async def batch_chat_endpoint(batch_id: str, request: Request): """Send a message to the batch chat and get AI response.""" body = await request.json() user_message = body.get("message", "").strip() if not user_message: raise HTTPException(400, "Message is required") batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one') if not batch: raise HTTPException(404, "Batch not found") # Save user message db.execute( "INSERT INTO batch_chat (batch_id, role, content) VALUES (%s, 'user', %s)", (batch_id, user_message) ) # Build batch context for AI items = db.query(""" SELECT bi.*, c.first_name, c.last_name FROM batch_items bi JOIN candidates c ON bi.candidate_id = c.id WHERE bi.batch_id = %s AND bi.status != 'removed' ORDER BY bi.position_title, bi.match_score DESC """, (batch_id,)) context_parts = [ f"Batch: {batch['name']}", f"Description: {batch.get('description', '')}", f"Positions: {json.dumps(batch.get('positions', []), indent=2)}", f"\nMatched Candidates:", ] for item in items: context_parts.append( f" - {item['first_name']} {item['last_name']} → {item['position_title']} " f"(score: {item['match_score']}, status: {item['status']})" ) batch_context = "\n".join(context_parts) # Get chat history history = db.query("SELECT role, content FROM batch_chat WHERE batch_id = %s ORDER BY created_at ASC", (batch_id,)) history_list = [dict(r) for r in history] # Get AI response try: ai_response = ai.batch_chat(user_message, batch_context, history_list) except Exception as e: ai_response = f"Sorry, I couldn't process that: {str(e)}" # Save AI response db.execute( "INSERT INTO batch_chat (batch_id, role, content) VALUES (%s, 'assistant', %s)", (batch_id, ai_response) ) return {"response": ai_response} # ============================================================ # SERVE FRONTEND # ============================================================ @app.get("/") async def index(): return FileResponse(str(static_dir / "index.html")) @app.get("/{path:path}") async def serve_static(path: str): """Serve static files or return index.html for SPA routes.""" file_path = static_dir / path if file_path.exists() and file_path.is_file(): response = FileResponse(str(file_path)) # Prevent caching of JS/CSS so code changes take effect without hard refresh if path.endswith(('.js', '.css')): response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' return response return FileResponse(str(static_dir / "index.html"))