diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index a3a8d62..b18247e 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/main.py b/main.py index d90e572..c447f46 100644 --- a/main.py +++ b/main.py @@ -1075,6 +1075,98 @@ async def batch_chat_endpoint(batch_id: str, request: Request): return {"response": ai_response} +@app.put("/api/batches/{batch_id}/positions") +async def update_batch_positions(batch_id: str, request: Request): + """Update the positions array on a batch (add/edit/remove positions).""" + body = await request.json() + positions = body.get("positions", []) + row = db.execute( + "UPDATE cv_batches SET positions = %s, updated_at = NOW() WHERE id = %s RETURNING *", + (Json(positions), batch_id) + ) + if not row: + raise HTTPException(404, "Batch not found") + return dict(row) + + +@app.post("/api/batches/{batch_id}/generate") +async def generate_batch_cvs(batch_id: str): + """Generate CVs for all approved items in the batch using the selected template.""" + import httpx + + batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one') + if not batch: + raise HTTPException(404, "Batch not found") + + template_id = batch.get("template_id") + if not template_id: + raise HTTPException(400, "No template selected for this batch") + + # Get approved items with candidate data + items = db.query(""" + SELECT bi.*, c.first_name, c.last_name, c.email, c.phone, c.address, + c.linkedin, c.github, c.website, c.summary, c.raw_cv_text + FROM batch_items bi + JOIN candidates c ON bi.candidate_id = c.id + WHERE bi.batch_id = %s AND bi.status = 'approved' + ORDER BY bi.position_title, bi.match_score DESC + """, (batch_id,)) + + if not items: + raise HTTPException(400, "No approved candidates to generate CVs for") + + # For each approved item, fetch full candidate data and call Carbone render + results = [] + carbone_url = f"http://localhost:8771/api/carbone/templates/{template_id}/render" + + for item in items: + candidate_id = str(item["candidate_id"]) + + # Fetch full candidate data (skills, experience, education, certs) + cand = dict(db.query("SELECT * FROM candidates WHERE id = %s", (candidate_id,), fetch='one')) + cand["skills"] = [dict(s) for s in db.query("SELECT * FROM skills WHERE candidate_id = %s", (candidate_id,))] + cand["experience"] = [dict(e) for e in db.query("SELECT * FROM experience WHERE candidate_id = %s", (candidate_id,))] + cand["education"] = [dict(e) for e in db.query("SELECT * FROM education WHERE candidate_id = %s", (candidate_id,))] + cand["certifications"] = [dict(c) for c in db.query("SELECT * FROM certifications WHERE candidate_id = %s", (candidate_id,))] + + # Use realigned data if available, else original + realigned = item.get("realigned_cv_data") + if realigned and isinstance(realigned, dict) and realigned.get("realignment_suggestion"): + # For now, use original data — realignment is advisory only + pass + + try: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(carbone_url, json={"candidateData": cand}) + + if resp.status_code == 200: + # Save the PDF + import tempfile + pdf_dir = os.path.join(str(static_dir), "generated") + os.makedirs(pdf_dir, exist_ok=True) + safe_name = f"{item['first_name']}_{item['last_name']}".replace(" ", "_") + filename = f"{batch['name'].replace(' ', '_')}_{safe_name}_{candidate_id[:8]}.pdf" + filepath = os.path.join(pdf_dir, filename) + with open(filepath, "wb") as f: + f.write(resp.content) + + # Update batch item with path + db.execute( + "UPDATE batch_items SET generated_cv_path = %s, updated_at = NOW() WHERE id = %s", + (f"/static/generated/{filename}", item["id"]) + ) + results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "ok", "file": filename}) + else: + results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": resp.text[:200]}) + except Exception as e: + results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": str(e)}) + + # Update batch status to exported + db.execute("UPDATE cv_batches SET status = 'exported', updated_at = NOW() WHERE id = %s", (batch_id,)) + + return {"success": True, "generated": len([r for r in results if r["status"] == "ok"]), "results": results} + + # ============================================================ # SERVE FRONTEND # ============================================================ diff --git a/static/batches.js b/static/batches.js index 8d98069..d0dcbea 100644 --- a/static/batches.js +++ b/static/batches.js @@ -209,6 +209,7 @@ function renderBatchDetail(data) { ${item.match_score}% ${item.status} ${item.match_reasoning ? '
' + escapeHtml(item.match_reasoning) + '
' : ''} + ${item.generated_cv_path ? 'Download CV' : ''}
${item.status !== 'approved' ? '' : ''} @@ -253,6 +254,7 @@ function renderBatchDetail(data) {
+ @@ -271,6 +273,41 @@ function renderBatchDetail(data) {
${positionsHtml} +
+
+ Add Position + +
+ +
`; } @@ -292,6 +329,89 @@ async function saveBatchTemplate() { } } +// ============================================================ +// ADD POSITION +// ============================================================ +function showAddPositionForm() { + const form = document.getElementById('add-position-form'); + form.style.display = form.style.display === 'none' ? 'block' : 'none'; +} + +async function addPosition() { + const title = document.getElementById('new-pos-title').value.trim(); + if (!title) { toast('Job title required', 'error'); return; } + const qty = parseInt(document.getElementById('new-pos-qty').value) || 1; + const years = document.getElementById('new-pos-years').value ? parseInt(document.getElementById('new-pos-years').value) : null; + const skills = document.getElementById('new-pos-skills').value.split(',').map(s => s.trim()).filter(s => s); + const certs = document.getElementById('new-pos-certs').value.split(',').map(s => s.trim()).filter(s => s); + const desc = document.getElementById('new-pos-desc').value.trim(); + + const positions = currentBatch.batch.positions || []; + positions.push({ + job_title: title, + num_positions: qty, + required_years: years, + required_skills: skills, + required_certs: certs, + nice_to_have: [], + disqualifiers: [], + description: desc + }); + + try { + await fetch('/api/batches/' + currentBatchId + '/positions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ positions }) + }); + toast('Position added'); + openBatch(currentBatchId); + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + +// ============================================================ +// GENERATE CVs +// ============================================================ +async function generateBatchCVs() { + if (!currentBatchId) return; + + const templateId = currentBatch.batch.template_id; + if (!templateId) { + toast('Select a template first', 'error'); + return; + } + + const approvedCount = (currentBatch.items || []).filter(i => i.status === 'approved').length; + if (approvedCount === 0) { + toast('No approved candidates to generate CVs for', 'error'); + return; + } + + if (!confirm('Generate ' + approvedCount + ' CV(s) using the selected template?')) return; + toast('Generating CVs... this may take a minute'); + + try { + const resp = await fetch('/api/batches/' + currentBatchId + '/generate', { method: 'POST' }); + const data = await resp.json(); + if (resp.ok) { + const ok = data.generated || 0; + const failed = (data.results || []).filter(r => r.status === 'error').length; + if (failed > 0) { + toast('Generated ' + ok + ' CVs, ' + failed + ' failed', 'error'); + } else { + toast('Generated ' + ok + ' CVs successfully'); + } + openBatch(currentBatchId); + } else { + toast('Error: ' + (data.detail || 'Generation failed'), 'error'); + } + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + function closeBatchDetail() { document.getElementById('batches-list-view').style.display = 'block'; document.getElementById('batch-detail-view').style.display = 'none'; diff --git a/static/index.html b/static/index.html index 574a7a3..534a37f 100644 --- a/static/index.html +++ b/static/index.html @@ -203,6 +203,6 @@ - + \ No newline at end of file