Files
cv-app/main.py

844 lines
34 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 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"]))
# ============================================================
# 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"))