feat: CV Batches system with AI extraction, matching, and slide-in chat
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
157
ai_service.py
157
ai_service.py
@@ -456,3 +456,160 @@ 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)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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)
|
||||
55
database.py
55
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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
249
main.py
249
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
|
||||
# ============================================================
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
379
static/batches.js
Normal file
379
static/batches.js
Normal file
@@ -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 = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/batches');
|
||||
const data = await resp.json();
|
||||
if (!data.batches || !data.batches.length) {
|
||||
list.innerHTML = '<p class="text-muted">No batches yet. Create one manually or upload a requirements document.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.batches.map(b => `
|
||||
<div class="card mb-16" style="cursor:pointer" onclick="openBatch('${b.id}')">
|
||||
<div class="card-header">
|
||||
<span class="card-title">${b.name}</span>
|
||||
<span class="badge badge-${b.status === 'active' ? 'green' : b.status === 'exported' ? 'blue' : 'orange'}">${b.status}</span>
|
||||
</div>
|
||||
${b.description ? '<p class="text-sm text-muted" style="margin-top:8px">' + escapeHtml(b.description) + '</p>' : ''}
|
||||
<div class="text-muted text-sm" style="margin-top:8px">
|
||||
Created: ${new Date(b.created_at).toLocaleDateString()}
|
||||
${b.positions && b.positions.length ? ' · Positions: ' + b.positions.length : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CREATE BATCH (manual)
|
||||
// ============================================================
|
||||
function showCreateBatchModal() {
|
||||
const body = `
|
||||
<div class="form-group">
|
||||
<label>Batch Name</label>
|
||||
<input type="text" id="new-batch-name" placeholder="e.g. Senior DevOps Team">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea id="new-batch-desc" placeholder="What is this batch for?"></textarea>
|
||||
</div>
|
||||
<button class="btn" onclick="createBatch()">Create</button>
|
||||
`;
|
||||
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 = `
|
||||
<div class="upload-zone" id="batch-upload-zone" onclick="document.getElementById('batch-file-input').click()">
|
||||
<p style="font-size:16px; margin-bottom:8px">Drop requirements document here or click to browse</p>
|
||||
<p class="text-muted text-sm">PDF, DOCX, or TXT — AI will extract job positions automatically</p>
|
||||
</div>
|
||||
<input type="file" id="batch-file-input" accept=".pdf,.docx,.doc,.txt,.rtf" style="display:none" onchange="uploadBatchDoc(this.files[0])">
|
||||
<div id="batch-upload-status" style="margin-top:12px"></div>
|
||||
`;
|
||||
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 = '<div class="loading"><span class="spinner"></span> Extracting requirements with AI... this may take 30-60 seconds</div>';
|
||||
|
||||
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 = '<div class="toast success" style="position:relative">Batch created! Extracted ' + (data.positions?.length || 0) + ' positions.</div>';
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
openBatch(data.id);
|
||||
}, 1500);
|
||||
toast('Batch created from document');
|
||||
} else {
|
||||
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + (data.detail || 'Unknown error') + '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 = '<div class="loading">Loading batch...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/batches/' + batchId);
|
||||
const data = await resp.json();
|
||||
currentBatch = data;
|
||||
renderBatchDetail(data);
|
||||
} catch (e) {
|
||||
detail.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="card mb-16"><div class="card-header"><span class="card-title">Positions</span></div>';
|
||||
positions.forEach(p => {
|
||||
const posItems = byPosition[p.job_title] || [];
|
||||
positionsHtml += `
|
||||
<div style="margin-bottom:20px">
|
||||
<h4 style="color:var(--accent)">${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}</h4>
|
||||
${p.description ? '<p class="text-sm text-muted">' + escapeHtml(p.description) + '</p>' : ''}
|
||||
${p.required_skills && p.required_skills.length ? '<div class="text-sm">Required: ' + p.required_skills.map(s => '<span class="badge badge-blue">' + escapeHtml(s) + '</span>').join(' ') + '</div>' : ''}
|
||||
${p.required_years ? '<div class="text-sm text-muted">Min years: ' + p.required_years + '</div>' : ''}
|
||||
${p.required_certs && p.required_certs.length ? '<div class="text-sm">Certs: ' + p.required_certs.map(c => '<span class="badge badge-orange">' + escapeHtml(c) + '</span>').join(' ') + '</div>' : ''}
|
||||
${posItems.length ? `
|
||||
<div style="margin-top:12px">
|
||||
${posItems.map(item => `
|
||||
<div class="skill-row" style="margin-bottom:8px">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')}</strong>
|
||||
<span class="badge badge-${item.match_score >= 70 ? 'green' : item.match_score >= 50 ? 'orange' : 'red'}">${item.match_score}%</span>
|
||||
<span class="badge badge-${item.status === 'approved' ? 'green' : item.status === 'removed' ? 'red' : 'orange'}">${item.status}</span>
|
||||
${item.match_reasoning ? '<div class="text-muted text-sm" style="margin-top:4px">' + escapeHtml(item.match_reasoning) + '</div>' : ''}
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
${item.status !== 'approved' ? '<button class="btn btn-sm btn-green" onclick="approveItem(\'' + item.id + '\')">Approve</button>' : ''}
|
||||
${item.status !== 'removed' ? '<button class="btn btn-sm btn-danger" onclick="removeItem(\'' + item.id + '\')">Remove</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<p class="text-muted text-sm">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
positionsHtml += '</div>';
|
||||
}
|
||||
|
||||
// Unassigned items
|
||||
const unassigned = byPosition['Unassigned'] || [];
|
||||
if (unassigned.length) {
|
||||
positionsHtml += '<div class="card mb-16"><div class="card-header"><span class="card-title">Unassigned</span></div>';
|
||||
positionsHtml += unassigned.map(item => `
|
||||
<div class="skill-row">
|
||||
<div><strong>${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')}</strong> <span class="badge badge-orange">${item.match_score}%</span></div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-sm btn-danger" onclick="removeItem('${item.id}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
positionsHtml += '</div>';
|
||||
}
|
||||
|
||||
document.getElementById('batch-detail-view').innerHTML = `
|
||||
<div class="flex-between mb-16">
|
||||
<div>
|
||||
<h2>${escapeHtml(batch.name)}</h2>
|
||||
<p class="text-muted text-sm">${escapeHtml(batch.description || '')}</p>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn" onclick="analyzeBatch()">Analyze & Match</button>
|
||||
<button class="btn btn-outline" onclick="openBatchChat()">Discuss</button>
|
||||
<button class="btn btn-danger" onclick="deleteBatch()">Delete</button>
|
||||
<button class="btn btn-outline" onclick="closeBatchDetail()">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
${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 = '<div class="text-muted">Start discussing this batch with the AI...</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = messages.map(m => `
|
||||
<div class="chat-msg ${m.role}">
|
||||
<div class="bubble">${escapeHtml(m.content)}</div>
|
||||
</div>
|
||||
`).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 += `<div class="chat-msg user"><div class="bubble">${escapeHtml(message)}</div></div>`;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Show loading
|
||||
container.innerHTML += '<div class="chat-msg assistant" id="chat-loading"><div class="bubble">Thinking...</div></div>';
|
||||
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 += `<div class="chat-msg assistant"><div class="bubble">${escapeHtml(data.response)}</div></div>`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -20,9 +20,9 @@
|
||||
<li><a href="#" class="nav-link" data-page="candidates">Candidates</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="upload">Upload CV</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="templates">Templates</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="batches">Batches</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="requirements">Requirements</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="generated">Generated CVs</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="chat">AI Chat</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -105,6 +105,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BATCHES -->
|
||||
<div id="page-batches" class="page">
|
||||
<div id="batches-list-view">
|
||||
<h2 style="margin-bottom:20px">CV Batches</h2>
|
||||
<div class="flex mb-16 gap-8">
|
||||
<button class="btn" onclick="showCreateBatchModal()">+ New Batch</button>
|
||||
<button class="btn btn-outline" onclick="showUploadBatchModal()">Upload Document</button>
|
||||
</div>
|
||||
<div id="batches-list"></div>
|
||||
</div>
|
||||
<div id="batch-detail-view" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- REQUIREMENTS -->
|
||||
<div id="page-requirements" class="page">
|
||||
<h2 style="margin-bottom:20px">Requirement Requests</h2>
|
||||
@@ -119,27 +132,27 @@
|
||||
<h2 style="margin-bottom:20px">Generated CVs</h2>
|
||||
<div id="generated-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- CHAT -->
|
||||
<div id="page-chat" class="page">
|
||||
<h2 style="margin-bottom:20px">AI Chat Assistant</h2>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="text-muted">Start a conversation with the AI assistant...</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type your message..." rows="1"></textarea>
|
||||
<button class="btn" onclick="sendChat()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal-overlay" id="modal-overlay"></div>
|
||||
|
||||
<script src="/static/app.js?v=15"></script>
|
||||
<!-- Chat slide-in panel -->
|
||||
<div id="batch-chat-panel" class="chat-panel" style="display:none">
|
||||
<div class="chat-panel-header">
|
||||
<h3>Batch Discussion</h3>
|
||||
<button class="btn btn-sm btn-danger" onclick="closeBatchChat()">Close</button>
|
||||
</div>
|
||||
<div class="chat-panel-messages" id="batch-chat-messages"></div>
|
||||
<div class="chat-panel-input">
|
||||
<textarea id="batch-chat-input" placeholder="Type your message..." rows="2"></textarea>
|
||||
<button class="btn" onclick="sendBatchChat()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js?v=16"></script>
|
||||
<script src="/static/carbone.js?v=16"></script>
|
||||
<script src="/static/batches.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user