"""CV Application - Main FastAPI Server.""" import json import os import uuid from datetime import date, datetime from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pathlib import Path import database as db 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 # ============================================================ # 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(): return FileResponse(str(file_path)) return FileResponse(str(static_dir / "index.html"))