feat: CV Application - AI-powered CV management, parsing, matching, and generation
This commit is contained in:
707
main.py
Normal file
707
main.py
Normal file
@@ -0,0 +1,707 @@
|
||||
"""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"))
|
||||
Reference in New Issue
Block a user