From 29c0a0c348556bef80ff0cf15d446bebb7263447 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 19:22:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20closed=20batch=20loop=20=E2=80=94=20man?= =?UTF-8?q?ual=20positions,=20Generate=20CVs,=20download=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/main.cpython-312.pyc | Bin 61600 -> 67320 bytes main.py | 92 ++++++++++++++++++++++++ static/batches.js | 120 +++++++++++++++++++++++++++++++ static/index.html | 2 +- 4 files changed, 213 insertions(+), 1 deletion(-) diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index a3a8d62aa36970ac9b21036d0630811feb82c570..b18247e03c433454e9bb373dc7df76ca13864847 100644 GIT binary patch delta 7608 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 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