feat: CV Batches system with AI extraction, matching, and slide-in chat

This commit is contained in:
root
2026-07-25 09:41:26 +00:00
parent 692b383aa5
commit ecb9ebb507
10 changed files with 910 additions and 18 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -456,3 +456,160 @@ def chat(user_message: str, conversation_history: list = None, context: str = ""
messages.append({"role": "user", "content": user_message}) messages.append({"role": "user", "content": user_message})
return llm_chat(messages, temperature=0.5, max_tokens=2000) 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)

View File

@@ -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_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_generated_cvs_requirement ON generated_cvs(requirement_request_id);
CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation ON chat_messages(conversation_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
View File

@@ -12,6 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path from pathlib import Path
import database as db import database as db
from database import Json
from config import get_settings from config import get_settings
from doc_parser import extract_text from doc_parser import extract_text
import ai_service as ai 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"])) 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 # SERVE FRONTEND
# ============================================================ # ============================================================

View File

@@ -19,7 +19,7 @@ document.querySelectorAll('.nav-link').forEach(link => {
if (page === 'templates') loadCarboneTemplates(); if (page === 'templates') loadCarboneTemplates();
if (page === 'requirements') loadRequirements(); if (page === 'requirements') loadRequirements();
if (page === 'generated') loadGeneratedCVs(); if (page === 'generated') loadGeneratedCVs();
if (page === 'chat') loadChat(); if (page === 'batches') loadBatches();
}); });
}); });

379
static/batches.js Normal file
View 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();
}
});
}
});

View File

@@ -20,9 +20,9 @@
<li><a href="#" class="nav-link" data-page="candidates">Candidates</a></li> <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="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="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="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="generated">Generated CVs</a></li>
<li><a href="#" class="nav-link" data-page="chat">AI Chat</a></li>
</ul> </ul>
</nav> </nav>
@@ -105,6 +105,19 @@
</div> </div>
</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 --> <!-- REQUIREMENTS -->
<div id="page-requirements" class="page"> <div id="page-requirements" class="page">
<h2 style="margin-bottom:20px">Requirement Requests</h2> <h2 style="margin-bottom:20px">Requirement Requests</h2>
@@ -119,27 +132,27 @@
<h2 style="margin-bottom:20px">Generated CVs</h2> <h2 style="margin-bottom:20px">Generated CVs</h2>
<div id="generated-list"></div> <div id="generated-list"></div>
</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> </main>
</div> </div>
<!-- Modals --> <!-- Modals -->
<div class="modal-overlay" id="modal-overlay"></div> <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/carbone.js?v=16"></script>
<script src="/static/batches.js?v=1"></script>
</body> </body>
</html> </html>

View File

@@ -215,6 +215,45 @@ tr:hover { background: var(--bg-input); }
.chat-input { display: flex; gap: 8px; } .chat-input { display: flex; gap: 8px; }
.chat-input textarea { flex: 1; min-height: 44px; max-height: 120px; } .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 */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;