feat: CV Batches system with AI extraction, matching, and slide-in chat
This commit is contained in:
249
main.py
249
main.py
@@ -12,6 +12,7 @@ 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
|
||||
@@ -826,6 +827,254 @@ async def render_generated_cv_pdf(gen_id: str):
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user