diff --git a/__pycache__/ai_service.cpython-312.pyc b/__pycache__/ai_service.cpython-312.pyc index ff6af19..2f4b422 100644 Binary files a/__pycache__/ai_service.cpython-312.pyc and b/__pycache__/ai_service.cpython-312.pyc differ diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index c2c96f7..051e771 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 6510535..0935591 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/ai_service.py b/ai_service.py index 6498953..d89afd8 100644 --- a/ai_service.py +++ b/ai_service.py @@ -455,4 +455,161 @@ def chat(user_message: str, conversation_history: list = None, context: str = "" messages.append({"role": "user", "content": user_message}) - return llm_chat(messages, temperature=0.5, max_tokens=2000) \ No newline at end of file + return llm_chat(messages, temperature=0.5, max_tokens=2000) + + +# ============================================================ +# BATCH FUNCTIONS — extract requirements, match candidates, batch chat +# ============================================================ + +EXTRACT_REQUIREMENTS_PROMPT = """You are a requirements analyst. Analyze the following document and extract the job positions and their requirements. + +Return ONLY a valid JSON object with this structure: +{ + "batch_name": "Short name for this batch (from the document title or first heading)", + "description": "Brief description of what this batch is for (1-2 sentences)", + "positions": [ + { + "job_title": "Position title", + "num_positions": 1, + "required_skills": ["skill1", "skill2"], + "required_years": 5, + "required_certs": ["cert1"], + "nice_to_have": ["skill3"], + "disqualifiers": ["something that would disqualify a candidate"], + "description": "Brief description of the role" + } + ] +} + +Rules: +- Extract ALL positions mentioned in the document. +- If a field is not specified, use empty string "" or empty array []. +- Be precise about required skills — distinguish must-haves from nice-to-haves. +- If no positions are found, return an empty positions array. +- Return ONLY the JSON, no other text. + +Document text: +--- +__DOC_TEXT__ +---""" + +def extract_requirements(doc_text: str) -> dict: + """Extract job requirements from a document using AI.""" + max_chars = 15000 + if len(doc_text) > max_chars: + doc_text = doc_text[:max_chars] + "\n[...truncated...]" + + prompt = EXTRACT_REQUIREMENTS_PROMPT.replace("__DOC_TEXT__", doc_text) + messages = [ + {"role": "system", "content": "You are a requirements analyst that outputs only valid JSON. Make sure all string values are properly escaped."}, + {"role": "user", "content": prompt} + ] + + for max_tokens in [4000, 6000, 8000]: + try: + resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens) + return extract_json(resp) + except (ValueError, json.JSONDecodeError): + if max_tokens == 8000: + raise + continue + + +MATCH_PROMPT = """You are a CV matching specialist. You are given a job position's requirements and a list of candidates with their skills, experience, education, and certifications. + +For each candidate, determine: +1. A match score (0-100) based on how well they meet the requirements +2. Whether they are a good fit (score >= 60) +3. A brief reasoning for the match or mismatch +4. If they are a close match (score 50-79), suggest how their CV could be realigned (reworded) to better fit — without fabricating skills they don't have + +Return ONLY a valid JSON array of matching candidates: +[ + { + "candidate_id": "the UUID", + "candidate_name": "First Last", + "match_score": 85, + "fit": true, + "reasoning": "Strong match: has 7 years Python, AWS cert, Kubernetes experience", + "realignment_suggestion": "Emphasize microservices architecture experience over generic 'distributed systems' wording; reorder skills to put AWS and Kubernetes first" + } +] + +Only include candidates with match_score >= 40. Sort by match_score descending. +Return ONLY the JSON array, no other text. + +Position Requirements: +__POSITION__ + +Candidates: +__CANDIDATES__""" + +def match_candidates_for_position(position: dict, candidates: list) -> list: + """Match candidates against a single position's requirements using AI.""" + # Build a compact summary of each candidate for the LLM + cand_summaries = [] + for c in candidates: + skills = ", ".join([s.get("skill_name", "") for s in c.get("skills", [])[:15]]) + exp = "; ".join([f"{e.get('position','')} at {e.get('company','')}" for e in c.get("experience", [])[:5]]) + certs = ", ".join([cert.get("name", "") for cert in c.get("certifications", [])[:5]]) + edu = "; ".join([f"{e.get('degree','')} {e.get('field_of_study','')} at {e.get('institution','')}" for e in c.get("education", [])[:3]]) + + cand_summaries.append(f"""Candidate ID: {c.get('id', 'N/A')} +Name: {c.get('first_name', '')} {c.get('last_name', '')} +Skills: {skills} +Experience: {exp} +Certifications: {certs} +Education: {edu} +Summary: {c.get('summary', '')[:200]}""") + + position_json = json.dumps(position, indent=2) + candidates_text = "\n\n---\n\n".join(cand_summaries) + + prompt = MATCH_PROMPT.replace("__POSITION__", position_json).replace("__CANDIDATES__", candidates_text) + messages = [ + {"role": "system", "content": "You are a CV matching specialist that outputs only valid JSON. Make sure all string values are properly escaped."}, + {"role": "user", "content": prompt} + ] + + for max_tokens in [4000, 6000, 8000]: + try: + resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens) + result = extract_json(resp) + if isinstance(result, list): + return result + elif isinstance(result, dict) and 'candidates' in result: + return result['candidates'] + return [result] + except (ValueError, json.JSONDecodeError): + if max_tokens == 8000: + raise + continue + return [] + + +BATCH_CHAT_SYSTEM = """You are an AI assistant helping a user manage a CV batch. You have context about the batch (name, description, positions, and matched candidates). + +You can help the user with: +1. Discussing which candidates are best for specific positions +2. Suggesting CV realignment — rewording a candidate's experience to better match a position (without fabricating) +3. Answering questions about candidate qualifications +4. Recommending which candidates to approve or remove + +Be concise and direct. When suggesting changes, be specific about what to change and why. + +Batch context: +__BATCH_CONTEXT__""" + +def batch_chat(user_message: str, batch_context: str, conversation_history: list = None) -> str: + """Context-aware chat for a specific batch.""" + system_content = BATCH_CHAT_SYSTEM.replace("__BATCH_CONTEXT__", batch_context) + messages = [{"role": "system", "content": system_content}] + + if conversation_history: + for msg in conversation_history[-10:]: + messages.append({"role": msg["role"], "content": msg["content"]}) + + messages.append({"role": "user", "content": user_message}) + + return llm_chat(messages, temperature=0.5, max_tokens=3000) \ No newline at end of file diff --git a/database.py b/database.py index 0834219..a3bb733 100644 --- a/database.py +++ b/database.py @@ -181,6 +181,61 @@ CREATE INDEX IF NOT EXISTS idx_certifications_candidate ON certifications(candid CREATE INDEX IF NOT EXISTS idx_generated_cvs_candidate ON generated_cvs(candidate_id); CREATE INDEX IF NOT EXISTS idx_generated_cvs_requirement ON generated_cvs(requirement_request_id); CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation ON chat_messages(conversation_id); + +-- ============================================================ +-- CV BATCHES — group candidates for a specific requirement +-- ============================================================ + +-- Main batch table (one per requirement document or manual creation) +CREATE TABLE IF NOT EXISTS cv_batches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + description TEXT, + -- Raw text of the uploaded requirements document (backend-only, AI use) + -- Null for manually created batches + requirements_text TEXT, + -- JSON array of positions extracted from the document + -- [{job_title, num_positions, required_skills, required_years, required_certs, description}] + positions JSONB DEFAULT '[]', + -- draft = still working, active = matching done, exported = CVs generated + status VARCHAR(50) DEFAULT 'draft', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Batch items (candidates linked to a batch) +CREATE TABLE IF NOT EXISTS batch_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + batch_id UUID REFERENCES cv_batches(id) ON DELETE CASCADE, + candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE, + -- Which position in the batch this candidate is matched to (free text) + position_title VARCHAR(255), + -- AI match score 0-100 + match_score INTEGER DEFAULT 0, + -- AI reasoning for the match + match_reasoning TEXT, + -- Realigned CV data (JSON — the reworded CV for this specific post) + realigned_cv_data JSONB, + -- proposed = AI suggested, approved = user confirmed, removed = user rejected + status VARCHAR(50) DEFAULT 'proposed', + -- Path to generated PDF after generation + generated_cv_path TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Batch chat (messages per batch's discussion) +CREATE TABLE IF NOT EXISTS batch_chat ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + batch_id UUID REFERENCES cv_batches(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, -- 'user' or 'assistant' + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_batch_items_batch ON batch_items(batch_id); +CREATE INDEX IF NOT EXISTS idx_batch_items_candidate ON batch_items(candidate_id); +CREATE INDEX IF NOT EXISTS idx_batch_chat_batch ON batch_chat(batch_id); """ diff --git a/main.py b/main.py index 1776ca5..1da0b1a 100644 --- a/main.py +++ b/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 # ============================================================ diff --git a/static/app.js b/static/app.js index 015e188..f400431 100644 --- a/static/app.js +++ b/static/app.js @@ -19,7 +19,7 @@ document.querySelectorAll('.nav-link').forEach(link => { if (page === 'templates') loadCarboneTemplates(); if (page === 'requirements') loadRequirements(); if (page === 'generated') loadGeneratedCVs(); - if (page === 'chat') loadChat(); + if (page === 'batches') loadBatches(); }); }); diff --git a/static/batches.js b/static/batches.js new file mode 100644 index 0000000..c3e9af7 --- /dev/null +++ b/static/batches.js @@ -0,0 +1,379 @@ +// CV Batches - Frontend +let currentBatchId = null; + +// ============================================================ +// NAV — load batches when page is shown +// ============================================================ +const batchNavObserver = new MutationObserver(() => { + if (document.getElementById('page-batches') && document.getElementById('page-batches').classList.contains('active')) { + loadBatches(); + } +}); +document.addEventListener('DOMContentLoaded', () => { + const bp = document.getElementById('page-batches'); + if (bp) { + if (bp.classList.contains('active')) loadBatches(); + batchNavObserver.observe(bp, { attributes: true, attributeFilter: ['class'] }); + } +}); + +// ============================================================ +// LIST BATCHES +// ============================================================ +async function loadBatches() { + const list = document.getElementById('batches-list'); + if (!list) return; + list.innerHTML = '
No batches yet. Create one manually or upload a requirements document.
'; + return; + } + list.innerHTML = data.batches.map(b => ` +' + escapeHtml(b.description) + '
' : ''} +Error: ' + e.message + '
'; + } +} + +// ============================================================ +// CREATE BATCH (manual) +// ============================================================ +function showCreateBatchModal() { + const body = ` +Drop requirements document here or click to browse
+PDF, DOCX, or TXT — AI will extract job positions automatically
+Error: ' + e.message + '
'; + } +} + +function renderBatchDetail(data) { + const batch = data.batch; + const items = data.items || []; + + // Group items by position + const byPosition = {}; + items.forEach(item => { + const pos = item.position_title || 'Unassigned'; + if (!byPosition[pos]) byPosition[pos] = []; + byPosition[pos].push(item); + }); + + // Build positions HTML + const positions = batch.positions || []; + let positionsHtml = ''; + if (positions.length) { + positionsHtml = '' + escapeHtml(p.description) + '
' : ''} + ${p.required_skills && p.required_skills.length ? 'No candidates matched yet. Click "Analyze & Match" to find candidates.
'} +${escapeHtml(batch.description || '')}
+