From ecb9ebb5079b2928c4e977b4d150ef59fcb8ecc5 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Jul 2026 09:41:26 +0000 Subject: [PATCH] feat: CV Batches system with AI extraction, matching, and slide-in chat --- __pycache__/ai_service.cpython-312.pyc | Bin 17508 -> 25055 bytes __pycache__/database.cpython-312.pyc | Bin 10007 -> 12415 bytes __pycache__/main.cpython-312.pyc | Bin 48264 -> 61525 bytes ai_service.py | 159 ++++++++++- database.py | 55 ++++ main.py | 249 ++++++++++++++++ static/app.js | 2 +- static/batches.js | 379 +++++++++++++++++++++++++ static/index.html | 45 +-- static/style.css | 39 +++ 10 files changed, 910 insertions(+), 18 deletions(-) create mode 100644 static/batches.js diff --git a/__pycache__/ai_service.cpython-312.pyc b/__pycache__/ai_service.cpython-312.pyc index ff6af19ee0396cff12b85a65e6afba7e17e09646..2f4b422faea2943fc2da35f48c422ac48372a3e8 100644 GIT binary patch delta 7346 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 delta 774 zcmZ9JUr19?9LMk3=G5!{Y0jzXHaEqsq99q&60S2YA>qyTC);RThv(`xV#;xl4od^E z=pWopMD-v&1wQ$3dr*3+p2C-2BHBasAcW6_(9`<;G>ANKKIeCS-{0@t?>*<+d$Mzv zIJWI}QGoI4bFS!#>5g_H>ittBpg-z~1gT?%WLjyEI$?EFSEdc(X03&`XdZe*vrwDn znQEY|D|MN6>dtgD3N)ziM7n#aS8Jv|DDuN<)0&~E2a3E4rf{FJOrjm6!ryd$3Wd$P zFP(x=nDq$4?vPIqre8eAu_djWSR2@4APDR#-#ofZvc}o2aSNH`y8jd@8gKm<2`O@W zz)SM{L?A>;Mk+9DCVAsoU!930_*(yHUlQA|0RA5QLE7DHf>at(VUbx-R=F#5@>mYT zAVNW{p3Pu9i^v12^-P;m=gN#-$OT!ERvHjwRV3N@oxi#37Rn2PSW#bPKTrsM@Br zl1g)B4eXlH?}LE|SvB4b>{;PfwxYxTZUyfDV?FfUz-ILI115~`5=(Rcxu4cMuz?dg z2B(Q`+#dXGi6k(Ce^qHbKT|4e^W~^m*sIlQ3QM5`k9ct7>^?^K5NW;<>uq|BAxAvn zUt_Piq}p2Wn@X4{VUxo8ArAX^RxOQWunK>+YN3V6GRxw=h$tZz_Lb`ec#cKfuOLxn>76r>|}^A7o`3GLRH3U diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index c2c96f7da2804a71cda31827d3d900a0496f9875..051e771c9769ac8c5e14ea71f5143869fecb973d 100644 GIT binary patch delta 1447 zcmbtTNoy2A6t)xNGMIqT;5H-=%`y{bFoFm!IV3S|Xq04JK&7qeu9;NQ-PKlAM}x=& zJPIBx#jAfoMuc3%qu|-2ApQW?tDvuHGBd4sOP#7-)%(6z@B7~Kncug2-gkFrHsE^o z&|mm6`m*PM)0uo9tRHb6?W<*qMHpYlGWk4+OoFFhN5asZ4FX^oIW;VG)#x^5#L2azG)+BMLDUX)5r!ml#gKYC;kv zBmhrrrbICw<#lb_z$Obp_4eMA`t!D9wOMi(6upND6;SgOk~rYRr+yk$wqbK+;w{q1 zFY+i@F2KY&NF>c?02kIclLUcnM1wk5Msnmvv<9oR4^yp?U9KV5kGEuNF_+AgDFGvb zcqXkPcxcON{;SD!9a?L!n04MCu%G< z>9#N(ufMv91AD)yS@>fbk69N-CafO<)oFGDH=-1rF<5!Xb~|K zN4MFo8daV@K8oMW%e*Jpm)EUIRHqX#P&Hn+K%E)ft#NpgDG69L!YrH_91a3G(?mNqhD%Uv zm9ZFJo2jv^t--;{RKrkcod%BUEx8uQYSC(cEI2+!oN?nrnN+J(Vml9_0x{k#*&DQ{ z0FOr%CPL~PFNCQZz1lZgYT~qoy-XBN8E;DjmrtDr6dSSDWJ9Z1-C8P|B4d+Rn!a8t z=(uyvD0!%*q&H6#b5Kee?QmX_8wY$Xe#&@(!?5w_0{Zk$N5q3?f>LB ozv@2+UUYPHIrV4z-*j~JIrS?;HD_z3vuEQ<=G*LM{c-sCA3jUu2mk;8 delta 81 zcmeyLFx`*uG%qg~0}z~_7M|6nxRFnpkvXwbdb0^*xAbOpwID{u-J7e_A22c=-5jmC lfQjiG!{)!*`KzeSI@|q&&0>l!1W=8b+W606#zLb8ifD= diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 6510535d981a7b129ea6c826d5c048e4282f3792..0935591c908c576dfbab433b2b0a9af6087b65ca 100644 GIT binary patch delta 17587 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 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;