1097 lines
44 KiB
Python
1097 lines
44 KiB
Python
"""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/<filename>", "html_url": "/api/html/<filename>"}
|
|
"""
|
|
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."""
|
|
body = await request.json()
|
|
row = db.execute(
|
|
"""UPDATE cv_batches SET name = %s, description = %s, updated_at = NOW()
|
|
WHERE id = %s RETURNING *""",
|
|
(body.get("name"), body.get("description"), 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")) |