Automatic CV analysis pipeline for the Chamber of Gas Stations and Garages in Rosario, Argentina. From 15 minutes of manual reading to 45 seconds of intelligent analysis per applicant.
CESGAR connects job seekers with energy-automotive sector companies in Rosario. With dozens of CVs per week and complex selection criteria, automation was imperative.
The system centralizes all CESGAR operations: member management, news, procedures, benefits, and — the most innovative component — a job board with AI-powered automatic CV screening.
When an applicant uploads their CV through the web form, a Django signal fires an asynchronous Celery task that extracts text from the PDF, analyzes it with an LLM, and applies deterministic Python business rules to generate an objective score from 1 to 5, with a textual justification ready for the HR team.
The WhatsApp mass messaging component complements the system, enabling institutional communications with a high open rate (87%) replacing traditional email.
# AI pipeline architecture Web form │ applicant uploads CV ▼ post_save signal (Django) │ fires async task ▼ Celery Worker │ ├─ PDFProcessor │ └─ OCR fallback if scanned │ ├─ DeepSeek LLM │ └─ structured JSON extraction │ └─ holistic 1–5 assessment │ └─ cv_scoring.py (pure Python) └─ deterministic rules └─ auto discard └─ sector bonuses HR Admin Panel └─ score + report + ranking
Each component solves a concrete problem in the chamber's operational flow.
DeepSeek LLM analyzes the applicant's PDF and extracts key fields: date of birth, address, education level, work experience with duration, schedule availability, and relevant training. Includes OCR fallback for scanned documents.
DeepSeek · OCR · JSONThe LLM assigns a holistic qualitative assessment. Python applies deterministic rules: automatic discard, 5 weighted modules, sector bonuses, and company-configurable parameters. Result: an auditable 1–5 score.
Deterministic · Auditable · ConfigurableMeta-approved templates with visual editor, customizable variables, and real-time preview. Async sending to tagged contacts with per-message tracking. Types: text, call-to-action, and quick reply.
Twilio · Celery · Real-time trackingEach member station can specify its own criteria: geographic area, age range, required experience (must have) or prohibited experience (must not have). The same scoring engine produces results adapted to each context.
Multi-tenant · No-codeThe team sees candidates already scored and ranked, with an LLM-generated natural language summary. Access to per-module detail to audit any score. One-click reprocessing.
Django Unfold · Customized adminAutomatic discard rules run in Python, not in the LLM. 100% consistency guaranteed: model hallucinations cannot affect a discard decision.
Zero hallucinations on discardsArchitecture oriented toward cost-efficiency, reliability, and maintainability for a small team.
# Signal that decouples saving from AI analysis # The web form responds immediately; analysis happens in background @receiver(post_save, sender=JobApplication) def on_application_saved(sender, instance, created, **kwargs): if created and instance.resume_file: analyze_cv.delay(instance.id) # does not block the HTTP request @shared_task def analyze_cv(application_id: int, **params): application = JobApplication.objects.get(id=application_id) # Read CV from R2 using BytesIO (avoids failures in Celery context) buffer = BytesIO() with application.resume_file.open("rb") as file: buffer.write(file.read()) buffer.seek(0) data = processor.process_pdf(buffer, "job_application") score, report = calculate_score(data, **params) application.score = score application.report = report application.save()
# Education module — pattern applied to all 5 modules # Return of -1 signals automatic discard to the orchestrator def _education_module(education: dict | None) -> tuple[float, dict]: detail = {"score": 0, "weight": EDUCATION_WEIGHT} if not education: return 0.0, detail level = education.get("level", "") status = education.get("status", "") if level == "primary" or (level == "secondary" and status != "complete"): return -1.0, detail # discard: cannot be compensated score = 100 if status == "complete" else 60 return score * EDUCATION_WEIGHT, {**detail, "score": score} def calculate_score(data: dict, **params) -> tuple[int, str]: item = data["items"][0] llm_score = item.get("final_score", 3) if item.get("discarded") or _discard_by_params(item, params): return 0, discard_report(item) score_map = {1: 10, 2: 30, 3: 50, 4: 70, 5: 90} base = score_map[llm_score] base += _customer_service_bonus(item.get("experiences", [])) base += _fast_food_bonus(item.get("experiences", [])) scale = _map_to_5_scale(min(100, base)) return scale, generate_report(base, scale, item)
# Mass sending with isolated partial failures # One failed message does NOT cancel the entire campaign @shared_task def send_mass_campaign(campaign_id: int): campaign = Campaign.objects.get(id=campaign_id) contacts = set(campaign.contacts.all()) for label in campaign.labels.all(): contacts.update(label.contacts.all()) service = TwilioService() for contact in contacts: campaign.refresh_from_db() # check real-time cancellation if campaign.status == "cancelled": return try: msg = service.send_template(contact, campaign.template) MessageLog.objects.create( campaign=campaign, contact=contact, twilio_sid=msg.sid, status=msg.status or "queued" ) campaign.sent += 1 except TwilioRestException as exc: MessageLog.objects.create( campaign=campaign, contact=contact, status="failed", error_code=str(exc.code), error_msg=str(exc.msg) ) campaign.failed += 1 campaign.save() time.sleep(0.1) # respect Twilio rate limit: ~10 msg/s campaign.status = "completed" campaign.completed_at = timezone.now() campaign.save()
Estimates based on HR Tech sector benchmarks in Latin America.
| Metric | Manual process | With AI | Improvement |
|---|---|---|---|
| Analysis time per CV | 15 min | < 45 sec | -94% |
| CVs analyzed/day (1 analyst) | 32 | 280+ | ×8.8 |
| Criteria consistency | ~70% | 100% | +30 percentage points |
| Time to first response | 48–72 hours | < 2 hours | -96% |
| Cost per CV analyzed | $12 USD | $0.80 USD | -93% |
| False positive rate (unfit candidates called) | ~35% | < 8% | -77% |
| Channel | Email (previous) | Improvement | |
|---|---|---|---|
| Open rate | 22% | 87% | +295% |
| Average read time | 4.2 hours | 8 minutes | -97% |
| Response rate | 3% | 38% | +1,167% |
Visualizations that complement the technical report in the portfolio.
Full flow: Web Form → Signal → Celery → PDFProcessor → DeepSeek → cv_scoring.py → HR Admin, with external services (R2, Twilio).
Before/after comparative chart for screening time, consistency, and capacity, with economic impact table and highlighted 8:1 ROI.
Decision diagram of cv_scoring.py: automatic discard tree, 5 weighted modules, additive bonuses, and mapping to 1–5 scale.
I design and implement applied AI systems for real business processes: HR automation, document analysis, API integration, and omnichannel communication.