feat: CV Batches system with AI extraction, matching, and slide-in chat

This commit is contained in:
root
2026-07-25 09:41:26 +00:00
parent 692b383aa5
commit ecb9ebb507
10 changed files with 910 additions and 18 deletions

249
main.py
View File

@@ -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
# ============================================================