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 = '
Loading...
'; + + try { + const resp = await fetch('/api/batches'); + const data = await resp.json(); + if (!data.batches || !data.batches.length) { + list.innerHTML = '

No batches yet. Create one manually or upload a requirements document.

'; + return; + } + list.innerHTML = data.batches.map(b => ` +
+
+ ${b.name} + ${b.status} +
+ ${b.description ? '

' + escapeHtml(b.description) + '

' : ''} +
+ Created: ${new Date(b.created_at).toLocaleDateString()} + ${b.positions && b.positions.length ? ' · Positions: ' + b.positions.length : ''} +
+
+ `).join(''); + } catch (e) { + list.innerHTML = '

Error: ' + e.message + '

'; + } +} + +// ============================================================ +// CREATE BATCH (manual) +// ============================================================ +function showCreateBatchModal() { + const body = ` +
+ + +
+
+ + +
+ + `; + showModal(body, 'New Batch'); +} + +async function createBatch() { + const name = document.getElementById('new-batch-name').value.trim(); + if (!name) { toast('Name required', 'error'); return; } + const description = document.getElementById('new-batch-desc').value.trim(); + + try { + const resp = await fetch('/api/batches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + const data = await resp.json(); + closeModal(); + toast('Batch created'); + openBatch(data.id); + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + +// ============================================================ +// UPLOAD DOCUMENT to create batch +// ============================================================ +function showUploadBatchModal() { + const body = ` +
+

Drop requirements document here or click to browse

+

PDF, DOCX, or TXT — AI will extract job positions automatically

+
+ +
+ `; + showModal(body, 'Upload Requirements Document'); + + // Setup drag-and-drop + const zone = document.getElementById('batch-upload-zone'); + zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', e => { + e.preventDefault(); + zone.classList.remove('dragover'); + if (e.dataTransfer.files.length) uploadBatchDoc(e.dataTransfer.files[0]); + }); +} + +async function uploadBatchDoc(file) { + if (!file) return; + const status = document.getElementById('batch-upload-status'); + status.innerHTML = '
Extracting requirements with AI... this may take 30-60 seconds
'; + + const formData = new FormData(); + formData.append('file', file); + + try { + const resp = await fetch('/api/batches/upload', { method: 'POST', body: formData }); + const data = await resp.json(); + if (resp.ok) { + status.innerHTML = '
Batch created! Extracted ' + (data.positions?.length || 0) + ' positions.
'; + setTimeout(() => { + closeModal(); + openBatch(data.id); + }, 1500); + toast('Batch created from document'); + } else { + status.innerHTML = '
Upload failed: ' + (data.detail || 'Unknown error') + '
'; + } + } catch (e) { + status.innerHTML = '
Upload failed: ' + e.message + '
'; + } +} + +// ============================================================ +// BATCH DETAIL VIEW +// ============================================================ +let currentBatch = null; + +async function openBatch(batchId) { + currentBatchId = batchId; + document.getElementById('batches-list-view').style.display = 'none'; + const detail = document.getElementById('batch-detail-view'); + detail.style.display = 'block'; + detail.innerHTML = '
Loading batch...
'; + + try { + const resp = await fetch('/api/batches/' + batchId); + const data = await resp.json(); + currentBatch = data; + renderBatchDetail(data); + } catch (e) { + detail.innerHTML = '

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 = '
Positions
'; + positions.forEach(p => { + const posItems = byPosition[p.job_title] || []; + positionsHtml += ` +
+

${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}

+ ${p.description ? '

' + escapeHtml(p.description) + '

' : ''} + ${p.required_skills && p.required_skills.length ? '
Required: ' + p.required_skills.map(s => '' + escapeHtml(s) + '').join(' ') + '
' : ''} + ${p.required_years ? '
Min years: ' + p.required_years + '
' : ''} + ${p.required_certs && p.required_certs.length ? '
Certs: ' + p.required_certs.map(c => '' + escapeHtml(c) + '').join(' ') + '
' : ''} + ${posItems.length ? ` +
+ ${posItems.map(item => ` +
+
+ ${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} + ${item.match_score}% + ${item.status} + ${item.match_reasoning ? '
' + escapeHtml(item.match_reasoning) + '
' : ''} +
+
+ ${item.status !== 'approved' ? '' : ''} + ${item.status !== 'removed' ? '' : ''} +
+
+ `).join('')} +
+ ` : '

No candidates matched yet. Click "Analyze & Match" to find candidates.

'} +
+ `; + }); + positionsHtml += '
'; + } + + // Unassigned items + const unassigned = byPosition['Unassigned'] || []; + if (unassigned.length) { + positionsHtml += '
Unassigned
'; + positionsHtml += unassigned.map(item => ` +
+
${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} ${item.match_score}%
+
+ +
+
+ `).join(''); + positionsHtml += '
'; + } + + document.getElementById('batch-detail-view').innerHTML = ` +
+
+

${escapeHtml(batch.name)}

+

${escapeHtml(batch.description || '')}

+
+
+ + + + +
+
+ ${positionsHtml} + `; +} + +function closeBatchDetail() { + document.getElementById('batches-list-view').style.display = 'block'; + document.getElementById('batch-detail-view').style.display = 'none'; + currentBatchId = null; + closeBatchChat(); + loadBatches(); +} + +// ============================================================ +// ANALYZE & MATCH +// ============================================================ +async function analyzeBatch() { + if (!currentBatchId) return; + toast('Analyzing candidates... this may take a minute'); + + try { + const resp = await fetch('/api/batches/' + currentBatchId + '/analyze', { method: 'POST' }); + const data = await resp.json(); + if (resp.ok) { + toast('Matched ' + data.matched + ' candidates'); + openBatch(currentBatchId); + } else { + toast('Error: ' + (data.detail || 'Analysis failed'), 'error'); + } + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + +// ============================================================ +// APPROVE / REMOVE ITEMS +// ============================================================ +async function approveItem(itemId) { + await fetch('/api/batches/' + currentBatchId + '/items/' + itemId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'approved' }) + }); + toast('Candidate approved'); + openBatch(currentBatchId); +} + +async function removeItem(itemId) { + await fetch('/api/batches/' + currentBatchId + '/items/' + itemId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'removed' }) + }); + toast('Candidate removed'); + openBatch(currentBatchId); +} + +// ============================================================ +// DELETE BATCH +// ============================================================ +async function deleteBatch() { + if (!currentBatchId || !confirm('Delete this batch and all its items?')) return; + await fetch('/api/batches/' + currentBatchId, { method: 'DELETE' }); + toast('Batch deleted'); + closeBatchDetail(); +} + +// ============================================================ +// BATCH CHAT (slide-in panel) +// ============================================================ +function openBatchChat() { + const panel = document.getElementById('batch-chat-panel'); + panel.style.display = 'flex'; + renderBatchChatMessages(currentBatch?.chat || []); +} + +function closeBatchChat() { + document.getElementById('batch-chat-panel').style.display = 'none'; +} + +function renderBatchChatMessages(messages) { + const container = document.getElementById('batch-chat-messages'); + if (!messages.length) { + container.innerHTML = '
Start discussing this batch with the AI...
'; + return; + } + container.innerHTML = messages.map(m => ` +
+
${escapeHtml(m.content)}
+
+ `).join(''); + container.scrollTop = container.scrollHeight; +} + +async function sendBatchChat() { + const input = document.getElementById('batch-chat-input'); + const message = input.value.trim(); + if (!message || !currentBatchId) return; + + input.value = ''; + // Show user message immediately + const container = document.getElementById('batch-chat-messages'); + container.innerHTML += `
${escapeHtml(message)}
`; + container.scrollTop = container.scrollHeight; + + // Show loading + container.innerHTML += '
Thinking...
'; + container.scrollTop = container.scrollHeight; + + try { + const resp = await fetch('/api/batches/' + currentBatchId + '/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }); + const data = await resp.json(); + + // Remove loading and show response + document.getElementById('chat-loading')?.remove(); + container.innerHTML += `
${escapeHtml(data.response)}
`; + container.scrollTop = container.scrollHeight; + } catch (e) { + document.getElementById('chat-loading')?.remove(); + toast('Chat error: ' + e.message, 'error'); + } +} + +// Enter key to send in chat +document.addEventListener('DOMContentLoaded', () => { + const input = document.getElementById('batch-chat-input'); + if (input) { + input.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendBatchChat(); + } + }); + } +}); \ No newline at end of file diff --git a/static/index.html b/static/index.html index 9634489..a4300b2 100644 --- a/static/index.html +++ b/static/index.html @@ -20,9 +20,9 @@
  • Candidates
  • Upload CV
  • Templates
  • +
  • Batches
  • Requirements
  • Generated CVs
  • -
  • AI Chat
  • @@ -105,6 +105,19 @@ + +
    +
    +

    CV Batches

    +
    + + +
    +
    +
    + +
    +

    Requirement Requests

    @@ -119,27 +132,27 @@

    Generated CVs

    - - -
    -

    AI Chat Assistant

    -
    -
    -
    Start a conversation with the AI assistant...
    -
    -
    - - -
    -
    -
    - + + + + + \ No newline at end of file diff --git a/static/style.css b/static/style.css index 9632e4d..0266696 100644 --- a/static/style.css +++ b/static/style.css @@ -215,6 +215,45 @@ tr:hover { background: var(--bg-input); } .chat-input { display: flex; gap: 8px; } .chat-input textarea { flex: 1; min-height: 44px; max-height: 120px; } +/* Slide-in chat panel for batch discussion */ +.chat-panel { + position: fixed; + top: 0; + right: 0; + width: 400px; + height: 100vh; + background: var(--bg-card); + border-left: 2px solid var(--border); + display: flex; + flex-direction: column; + z-index: 9999; + box-shadow: -4px 0 20px rgba(0,0,0,0.3); + transition: transform 0.3s ease; +} +.chat-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.chat-panel-messages { + flex: 1; + overflow-y: auto; + padding: 16px; +} +.chat-panel-input { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--border); +} +.chat-panel-input textarea { + flex: 1; + min-height: 44px; + max-height: 120px; +} + /* Modal */ .modal-overlay { position: fixed;