diff --git a/apx-cv-killer/.env.example b/apx-cv-killer/.env.example new file mode 100644 index 0000000..7baad74 --- /dev/null +++ b/apx-cv-killer/.env.example @@ -0,0 +1,5 @@ +# HrFlow.ai credentials (get from hackathon organizers) +HRFLOW_SECRET_KEY=your-hrflow-secret-key +HRFLOW_API_KEY=your-api-key-here +HRFLOW_SOURCE_KEY=your-source-key-here +GITHUB_TOKEN=your-github-token-here \ No newline at end of file diff --git a/apx-cv-killer/README.md b/apx-cv-killer/README.md new file mode 100644 index 0000000..d6c7582 --- /dev/null +++ b/apx-cv-killer/README.md @@ -0,0 +1,48 @@ +# Your App Name + +> Short tagline describing your app. + +## What it does + +Describe what your app does and the problem it solves. + +## HrFlow.ai APIs used + +- `GET /v1/profiles/searching` — Search candidate profiles +- Add more endpoints as needed + +## How to run + +### Prerequisites + +List what needs to be installed (e.g. Node.js 20+, Python 3.11+). + +### Setup + +```bash +# Install dependencies +npm install + +# Copy environment variables +cp .env.example .env +# Then fill in your actual API keys in .env + +# Start the app +npm start +``` + +### Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `HRFLOW_API_KEY` | Yes | HrFlow.ai API secret key | +| `HRFLOW_SOURCE_KEY` | Yes | HrFlow.ai source key | + +## Screenshots + +![Preview](./assets/preview.png) + +## Team + +- **Team Lead** — Lead +- **Developer** — Developer diff --git a/apx-cv-killer/app.json b/apx-cv-killer/app.json new file mode 100644 index 0000000..6b3aca2 --- /dev/null +++ b/apx-cv-killer/app.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../schemas/app.schema.json", + "name": "CV Killer", + "description": "AI recruitment tool", + "credentials": { + "source_keys": ["source_key"], + "board_keys": ["board_key"], + "algorithm_key": "algo_key" + }, + "settings": { + "team_name": "APX", + "theme_color": "#5B5BE1", + "custom_filters": [], + "filters": [] + } +} diff --git a/apx-cv-killer/assets/.env.example b/apx-cv-killer/assets/.env.example new file mode 100644 index 0000000..1487df0 --- /dev/null +++ b/apx-cv-killer/assets/.env.example @@ -0,0 +1,27 @@ +SECRET_KEY=your-secret-key-here-change-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database +DATABASE_URL=sqlite:///db.sqlite3 + +# Configuration SSH +SSH_HOST=192.168.0.25 +SSH_USER=hackathon-team2 +SSH_PASSWORD=password + +# Configuration OpenClaw +OPENCLAW_REMOTE_PORT=18789 +OPENCLAW_GATEWAY_TOKEN=openclaw_api_key + +# HrFlow +HRFLOW_API_KEY=your-hrflow-api-key +HRFLOW_SOURCE_KEY=your-hrflow-source-key +HRFLOW_BOARD_KEY=your_job_board_key +HRFLOW_BASE_URL=https://api.hrflow.ai/v1 + +# App Configuration +MAX_RESULTS=10 + +# Background Tasks (If using Celery/Redis) +CELERY_BROKER_URL=redis://localhost:6379/0 diff --git a/apx-cv-killer/assets/.gitkeep b/apx-cv-killer/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apx-cv-killer/assets/API_INTEGRATION_GUIDE.txt b/apx-cv-killer/assets/API_INTEGRATION_GUIDE.txt new file mode 100644 index 0000000..e14045f --- /dev/null +++ b/apx-cv-killer/assets/API_INTEGRATION_GUIDE.txt @@ -0,0 +1,251 @@ +""" +CV Killer - AI Sourcing Agent Integration Guide + +This module provides a quick reference for integrating OpenClaw and HrFlow APIs +""" + +# ============================================================================ +# OPENCLAW API INTEGRATION +# ============================================================================ + +""" +OpenClaw is used for intelligent web scraping to find candidate profiles. + +ENDPOINT: POST /search +BASE_URL: https://api.openclaw.io (configurable via OPENCLAW_BASE_URL) + +HEADERS: +{ + "Authorization": "Bearer {OPENCLAW_API_KEY}", + "Content-Type": "application/json" +} + +REQUEST PAYLOAD: +{ + "query": "Senior Python Developer with Django experience", + "sources": ["linkedin", "github", "portfolios", "cv_databases"], + "limit": 10, + "filters": { + "location": "optional", + "experience_level": "optional", + "skills": ["optional_list"] + } +} + +EXPECTED RESPONSE: +{ + "results": [ + { + "url": "https://linkedin.com/in/example", + "source": "linkedin", + "name": "John Doe", + "title": "Senior Developer", + "bio": "...", + "skills": ["Python", "Django", ...], + "experience": "10 years", + "raw_html": "...", + "..": "other profile data" + } + ], + "total_results": 150, + "processing_time": 2.3 +} + +IMPLEMENTATION: +See: sourcing/services.py -> OpenclawService.search_profiles() + +TODO: +1. Replace the placeholder endpoint with actual OpenClaw endpoint +2. Adjust request format based on OpenClaw API documentation +3. Add error handling for rate limiting +4. Implement pagination for large result sets +""" + + +# ============================================================================ +# HRFLOW.AI API INTEGRATION +# ============================================================================ + +""" +HrFlow.ai is used for: +1. Parsing CVs/profiles into standardized format +2. Scoring candidates against job requirements +3. Extracting skills, experience, and qualifications + +BASE_URL: https://api.hrflow.ai/v1 (configurable via HRFLOW_BASE_URL) + +AUTHENTICATION: +Uses API key headers - no bearer token needed + +HEADERS: +{ + "X-API-KEY": {HRFLOW_API_KEY}, + "Content-Type": "application/json" +} + + +## 1. PROFILES PARSING ENDPOINT +endpoint: POST /profiles/parsing + +REQUEST: +{ + "source_key": {HRFLOW_SOURCE_KEY}, + "data": { + "raw_text": "CV content as text", + "or_url": "https://profile.url" + } +} + +RESPONSE: +{ + "code": 200, + "profile": { + "id": "profile_id", + "name": "John Doe", + "email": "john@example.com", + "phone": "+33...", + "location": "Paris, France", + "summary": "...", + "experiences": [...], + "skills": [...], + "education": [...], + "certifications": [...] + } +} + +Implementation: sourcing/services.py -> HrFlowService.parse_profile() + + +## 2. PROFILES SCORING ENDPOINT +endpoint: POST /profiles/scoring + +REQUEST: +{ + "source_key": {HRFLOW_SOURCE_KEY}, + "job_id": "optional_job_id", + "job_description": "Full job description text", + "candidate": {parsed_profile_from_step_1} +} + +RESPONSE: +{ + "code": 200, + "score": 85, + "match_percentage": 88.5, + "skills": { + "matched": ["Python", "Django", "PostgreSQL"], + "missing": ["Kubernetes"], + "extra": ["Go"] + }, + "experience": { + "required_years": 5, + "candidate_years": 10, + "match": true + }, + "strengths": ["..."], + "gaps": ["..."] +} + +Implementation: sourcing/services.py -> HrFlowService.score_candidate() + + +## 3. PROFILES SEARCHING ENDPOINT +endpoint: GET /profiles/searching + +PARAMETERS: +- source_key: {HRFLOW_SOURCE_KEY} +- name: candidate name or "John*" for partial +- email: candidate email +- location: candidate location +- limit: results per page (default 10) +- offset: pagination offset + +RESPONSE: +{ + "code": 200, + "data": { + "profiles": [ + { + "id": "profile_id", + "name": "John Doe", + "email": "john@example.com", + ... + } + ], + "meta": { + "count": 1, + "total": 150 + } + } +} + +Implementation: sourcing/services.py -> HrFlowService.search_profiles() + + +# ============================================================================ +# WORKFLOW INTEGRATION +# ============================================================================ + +The complete workflow in SourcingService.process_job_offer(): + +1. Extract search query from job description + - Use NLP to identify key requirements + - or use job title as fallback + +2. Call OpenClaw to search profiles + - Send query with relevant filters + - Get list of candidates with profile URLs + +3. For each candidate: + a. Create CandidateProfile entry + b. Parse profile using HrFlow + c. Score against job using HrFlow + d. Store score in CandidateScore + +4. Display ranked results to recruiter + +5. Optional: Collect feedback for ML refinement + + +# ============================================================================ +# API KEY SETUP +# ============================================================================ + +To test the integration: + +1. Get API credentials: + - OpenClaw: https://openclaw.io/api + - HrFlow: https://api.hrflow.ai/ + +2. Update .env file: + OPENCLAW_API_KEY=your_key_here + HRFLOW_API_KEY=your_key_here + HRFLOW_SOURCE_KEY=your_source_key_here + +3. Test in Django shell: + python manage.py shell + >>> from sourcing.services import OpenclawService, HrFlowService + >>> openclaw = OpenclawService() + >>> # Test OpenClaw connection + >>> hrflow = HrFlowService() + >>> # Test HrFlow connection + + +# ============================================================================ +# QUICK REFERENCE +# ============================================================================ + +Files to modify: +- sourcing/services.py -> OpenclawService.search_profiles() +- sourcing/services.py -> HrFlowService.parse_profile() +- sourcing/services.py -> HrFlowService.score_candidate() + +Configuration: +- cv_killer/settings.py -> API endpoints and keys + +Testing: +- python manage.py test sourcing +- python manage.py shell + +# ============================================================================ +""" diff --git a/apx-cv-killer/assets/README.md b/apx-cv-killer/assets/README.md new file mode 100644 index 0000000..c7a6fa5 --- /dev/null +++ b/apx-cv-killer/assets/README.md @@ -0,0 +1,297 @@ +# CV Killer - AI Sourcing Agent + +> Intelligent AI-powered recruitment sourcing agent that finds candidate profiles across public web sources using OpenClaw and HrFlow.ai APIs. + +## 🎯 What it does + +CV Killer automates the candidate sourcing process by: +1. **Accepts job offers** - Upload job descriptions through a simple web interface +2. **Intelligent web scraping** - Uses OpenClaw to intelligently scrape public CV databases, LinkedIn profiles, GitHub portfolios, and other professional platforms +3. **Candidate parsing** - HrFlow.ai API parses raw CVs into standardized profiles +4. **Smart scoring** - Automatically scores and ranks candidates based on job requirements +5. **User feedback loop** - Captures recruiter feedback to refine results + +## 🏗️ Architecture + +``` +apx-cv-killer/ +├── assets/ +│ ├── manage.py # Django management command +│ ├── requirements.txt # Python dependencies +│ ├── .env.example # Environment variables template +│ ├── cv_killer/ # Django project settings +│ │ ├── settings.py # Django configuration +│ │ ├── urls.py # URL routing +│ │ └── wsgi.py # WSGI application +│ ├── sourcing/ # Main Django app +│ │ ├── models.py # Database models +│ │ ├── views.py # Request handlers +│ │ ├── services.py # Business logic (Openclaw, HrFlow) +│ │ ├── admin.py # Django admin configuration +│ │ └── urls.py # App URL routes +│ └── templates/ # HTML templates +│ ├── base.html # Base template +│ └── sourcing/ +│ ├── index.html # Upload & search +│ ├── results.html # Ranked results +│ └── candidate_detail.html # Individual profile +``` + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.10+ +- pip or poetry +- API Keys: + - OpenClaw API key + - HrFlow.ai API key and source key + +### Installation + +1. **Navigate to the assets folder:** +```bash +cd apx-cv-killer/assets +``` + +2. **Create a virtual environment:** +```bash +python -m venv venv + +# On Windows +venv\Scripts\activate + +# On macOS/Linux +source venv/bin/activate +``` + +3. **Install dependencies:** +```bash +pip install -r requirements.txt +``` + +4. **Setup environment variables:** +```bash +cp .env.example .env +# Then edit .env with your API keys and settings +``` + +5. **Run migrations:** +```bash +python manage.py migrate +``` + +6. **Create a superuser (optional, for admin access):** +```bash +python manage.py createsuperuser +``` + +7. **Start the development server:** +```bash +python manage.py runserver +``` + +The app will be available at `http://localhost:8000` + +## 📊 Database Models + +### JobOffer +Stores uploaded job descriptions and metadata +- `title`: Job position title +- `description`: Full job description +- `file`: Optional uploaded file (PDF/TXT/DOCX) +- `created_at`, `updated_at`: Timestamps + +### SearchSession +Tracks the sourcing workflow for each job offer +- `job_offer`: Foreign key to JobOffer +- `status`: pending → running → completed/failed +- `search_query`: Extracted search terms +- `openclaw_results_count`: Number of profiles found + +### CandidateProfile +Stores candidate profiles scraped from web sources +- `search_session`: Reference to search +- `source_url`: Profile URL +- `source_name`: Platform (LinkedIn, GitHub, etc.) +- `candidate_name`: Candidate's name +- `raw_data`: Raw profile data from OpenClaw + +### CandidateScore +HrFlow scoring and user feedback +- `candidate`: OneToOne with CandidateProfile +- `hrflow_score`: Numerical score (0-100) +- `match_percentage`: Match to job requirements +- `skills_match`: JSON of matched skills +- `user_feedback`: Recruiter feedback (relevant/maybe/irrelevant) + +## 🔌 API Integration + +### OpenClaw Integration + +The `OpenclawService` class in `sourcing/services.py` handles web scraping: + +```python +openclaw = OpenclawService() +results = openclaw.search_profiles(query, search_session) +``` + +**TODO:** Implement actual API endpoint calls. Currently has placeholder implementation. + +**Expected response:** +```json +{ + "results": [ + { + "url": "https://...", + "source": "linkedin", + "name": "John Doe", + "...": "raw profile data" + } + ] +} +``` + +### HrFlow.ai Integration + +The `HrFlowService` class handles parsing and scoring: + +```python +hrflow = HrFlowService() + +# Parse raw profile +parsed = hrflow.parse_profile(raw_data) + +# Score candidate against job +score = hrflow.score_candidate(job_description, parsed_profile) +``` + +**TODO:** Implement actual HrFlow API calls. Check HrFlow documentation for endpoints and parameters. + +## 🎨 Frontend Pages + +### 1. Upload Page (`/`) +- Simple form to upload job offer +- Drag & drop file upload +- Shows recent search history +- Real-time statistics + +### 2. Results Page (`/search//`) +- Ranked table of candidates +- Live search status indicator +- Match percentage visualization +- One-click feedback submission +- Automatic refresh while processing + +### 3. Candidate Detail (`/candidate//`) +- Full candidate profile view +- HrFlow score breakdown +- Matched skills display +- Direct link to source profile +- Feedback submission + +## ⚙️ Configuration + +Edit `.env` file to configure: + +| Variable | Required | Description | +|----------|----------|-------------| +| `SECRET_KEY` | Yes | Django secret key (change in production) | +| `DEBUG` | Yes | Debug mode (set to False in production) | +| `OPENCLAW_API_KEY` | Yes | OpenClaw API authentication | +| `OPENCLAW_BASE_URL` | Yes | OpenClaw API endpoint | +| `HRFLOW_API_KEY` | Yes | HrFlow.ai API key | +| `HRFLOW_SOURCE_KEY` | Yes | HrFlow.ai source ID | +| `HRFLOW_BASE_URL` | Yes | HrFlow.ai API endpoint | +| `MAX_RESULTS` | No | Max candidates to process (default: 10) | +| `ALLOWED_HOSTS` | No | Comma-separated list of allowed domains | + +## 🔄 Workflow + +1. User uploads job offer on frontend +2. Backend creates `JobOffer` and `SearchSession` objects +3. `SourcingService` starts in background thread: + - Extracts search query from job description + - Calls OpenClaw API to search for profiles + - For each result: + - Creates `CandidateProfile` entry + - Parses profile using HrFlow + - Scores candidate against job requirements + - Stores score in `CandidateScore` +4. Frontend displays results ranked by score +5. Recruiter can provide feedback for refinement + +## 👥 Admin Interface + +Access Django admin at `/admin/`: +- View all job offers and search sessions +- Inspect candidate profiles and scores +- Monitor search status and errors + +## 🛠️ Development + +### Run tests: +```bash +python manage.py test +``` + +### Create database migrations: +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### Django shell: +```bash +python manage.py shell +``` + +## 📝 Next Steps for Hackathon + +1. **Implement OpenClaw API calls** - Replace placeholders in `OpenclawService` +2. **Implement HrFlow API calls** - Add actual API integration in `HrFlowService` +3. **Add NLP for job parsing** - Improve `_extract_search_query()` method +4. **Add more sources** - Extend the web scraping to more platforms +5. **Implement caching** - Cache search results to improve performance +6. **Add analytics dashboard** - Show sourcing metrics and insights +7. **Deploy to production** - Use Gunicorn + PostgreSQL for production + +## 🚀 Deployment + +### Local Production Build: +```bash +python manage.py collectstatic --noinput +gunicorn cv_killer.wsgi:application --bind 0.0.0.0:8000 +``` + +### Environment for Production: +```env +SECRET_KEY=generate-a-strong-key +DEBUG=False +ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com +DATABASE_URL=postgresql://user:password@localhost/dbname +``` + +## 📚 Resources + +- [Django Documentation](https://docs.djangoproject.com/) +- [OpenClaw API Documentation](https://api.openclaw.io/docs) +- [HrFlow.ai API Documentation](https://api.hrflow.ai/docs) +- [Bootstrap 5 Documentation](https://getbootstrap.com/docs/5.0/) + +## 💡 API Endpoints + +- `GET /` - Upload page +- `POST /` - Submit job offer +- `GET /search//` - View search results +- `POST /candidate//feedback/` - Submit feedback +- `GET /candidate//` - View candidate details +- `GET /session//status/` - Check search status (JSON) + +## 🏆 Team + +Built for Hackathon - CV Killer Team + +--- + +**Good luck with your hackathon! 🚀** diff --git a/apx-cv-killer/assets/create_hrflow_job.py b/apx-cv-killer/assets/create_hrflow_job.py new file mode 100644 index 0000000..9645055 --- /dev/null +++ b/apx-cv-killer/assets/create_hrflow_job.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +""" +Create a job in HrFlow - this generates the job key needed for the sourcing app +""" +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +# Get credentials +HRFLOW_API_KEY = os.getenv('HRFLOW_API_KEY') +HRFLOW_BOARD_KEY = os.getenv('HRFLOW_BOARD_KEY') + +if not HRFLOW_API_KEY or not HRFLOW_BOARD_KEY: + print("❌ Error: HRFLOW_API_KEY and HRFLOW_BOARD_KEY must be set in .env") + exit(1) + +headers = { + "X-API-KEY": HRFLOW_API_KEY, + "Content-Type": "application/json" +} + +# Create a test job +job_payload = { + "board_key": HRFLOW_BOARD_KEY, + "name": "Senior Python Developer", + "description": "We are looking for a Senior Python Developer with Django experience to join our team.", + "location": "Remote", + "recruiter_email": os.getenv('HRFLOW_USER_EMAIL', 'your-email@example.com'), +} + +print("🚀 Creating job in HrFlow board...") +print(f"Board Key: {HRFLOW_BOARD_KEY}") + +try: + response = requests.post( + "https://api.hrflow.ai/v1/jobs/add", + json=job_payload, + headers=headers, + timeout=10 + ) + + if response.status_code == 201 or response.status_code == 200: + data = response.json() + if data.get('code') == 201 or data.get('data'): + job_key = data.get('data', {}).get('key') + job_id = data.get('data', {}).get('id') + + print("✅ Job created successfully!") + print(f"\n📋 Job Details:") + print(f" Job ID: {job_id}") + print(f" Job Key: {job_key}") + print(f"\n📝 Add to your .env:") + print(f" HRFLOW_JOB_KEY={job_key}") + + else: + print(f"⚠️ Response: {data}") + else: + print(f"❌ Error ({response.status_code}): {response.text}") + +except Exception as e: + print(f"❌ Exception: {e}") diff --git a/apx-cv-killer/assets/cv_killer/__init__.py b/apx-cv-killer/assets/cv_killer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apx-cv-killer/assets/cv_killer/settings.py b/apx-cv-killer/assets/cv_killer/settings.py new file mode 100644 index 0000000..e09ca45 --- /dev/null +++ b/apx-cv-killer/assets/cv_killer/settings.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-dev-key-change-in-production') + +DEBUG = os.getenv('DEBUG', 'True') == 'True' + +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'sourcing', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'cv_killer.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'cv_killer.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# ========================================== +# CUSTOM APPLICATION SETTINGS +# ========================================== + +# 1. SSH Configuration (For remote Mac Mini access) +SSH_HOST = os.getenv('SSH_HOST', '192.168.0.25') +SSH_USER = os.getenv('SSH_USER', 'hackathon-team2') +SSH_PASSWORD = os.getenv('SSH_PASSWORD', '') + +# 2. OpenClaw Configuration +OPENCLAW_REMOTE_PORT = int(os.getenv('OPENCLAW_REMOTE_PORT', '18789')) +OPENCLAW_GATEWAY_TOKEN = os.getenv('OPENCLAW_GATEWAY_TOKEN', '') +OPENCLAW_API_KEY = os.getenv('OPENCLAW_API_KEY', '') +OPENCLAW_BASE_URL = os.getenv('OPENCLAW_BASE_URL', '') + +# 3. HrFlow.ai Configuration +HRFLOW_API_KEY = os.getenv('HRFLOW_API_KEY', '') +HRFLOW_SOURCE_KEY = os.getenv('HRFLOW_SOURCE_KEY', '') +HRFLOW_BOARD_KEY = os.getenv('HRFLOW_BOARD_KEY', '') # Critical for the Scoring API +HRFLOW_BASE_URL = os.getenv('HRFLOW_BASE_URL', 'https://api.hrflow.ai/v1') +HRFLOW_USER_EMAIL = os.getenv('HRFLOW_USER_EMAIL', '') +HRFLOW_ALGORITHM_KEY = os.getenv('HRFLOW_ALGORITHM_KEY', '') + +# 4. App Limits & File Handling +MAX_RESULTS = int(os.getenv('MAX_RESULTS', '10')) +FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB + +# 5. Background Tasks (Celery) +CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0') +# Recommended Celery settings for Django: +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' \ No newline at end of file diff --git a/apx-cv-killer/assets/cv_killer/urls.py b/apx-cv-killer/assets/cv_killer/urls.py new file mode 100644 index 0000000..b75217d --- /dev/null +++ b/apx-cv-killer/assets/cv_killer/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('sourcing.urls')), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/apx-cv-killer/assets/cv_killer/wsgi.py b/apx-cv-killer/assets/cv_killer/wsgi.py new file mode 100644 index 0000000..3988543 --- /dev/null +++ b/apx-cv-killer/assets/cv_killer/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cv_killer.settings') +application = get_wsgi_application() diff --git a/apx-cv-killer/assets/db.sqlite3 b/apx-cv-killer/assets/db.sqlite3 new file mode 100644 index 0000000..434df17 Binary files /dev/null and b/apx-cv-killer/assets/db.sqlite3 differ diff --git a/apx-cv-killer/assets/manage.py b/apx-cv-killer/assets/manage.py new file mode 100644 index 0000000..c2ffe25 --- /dev/null +++ b/apx-cv-killer/assets/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cv_killer.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/apx-cv-killer/assets/preview.jpg b/apx-cv-killer/assets/preview.jpg new file mode 100644 index 0000000..7a85c9b Binary files /dev/null and b/apx-cv-killer/assets/preview.jpg differ diff --git a/apx-cv-killer/assets/requirements.txt b/apx-cv-killer/assets/requirements.txt new file mode 100644 index 0000000..a557806 --- /dev/null +++ b/apx-cv-killer/assets/requirements.txt @@ -0,0 +1,8 @@ +Django==4.2.0 +python-dotenv==1.0.0 +requests==2.31.0 +Pillow==10.0.0 +psycopg2-binary==2.9.7 +gunicorn==21.2.0 +paramiko==3.3.2 +hrflow==4.2.0 \ No newline at end of file diff --git a/apx-cv-killer/assets/setup.bat b/apx-cv-killer/assets/setup.bat new file mode 100644 index 0000000..06f1461 --- /dev/null +++ b/apx-cv-killer/assets/setup.bat @@ -0,0 +1,63 @@ +@echo off +REM CV Killer - Quick Setup Script for Windows + +echo. +echo 🚀 CV Killer - Setup Script +echo ================================ +echo. + +REM Check Python version +python --version +echo. + +REM Create virtual environment +echo 📦 Creating virtual environment... +python -m venv venv + +REM Activate virtual environment +echo 🔌 Activating virtual environment... +call venv\Scripts\activate.bat + +REM Install dependencies +echo. +echo ⬇️ Installing dependencies... +pip install -r requirements.txt + +REM Setup environment +echo. +echo ⚙️ Setting up environment... +if not exist .env ( + copy .env.example .env + echo ✓ Created .env file - IMPORTANT: Update with your API keys! +) else ( + echo ✓ .env file already exists +) + +REM Run migrations +echo. +echo 🗄️ Running database migrations... +python manage.py migrate + +REM Create superuser (optional) +echo. +set /p create_superuser="👤 Create superuser? (y/n): " +if "%create_superuser%"=="y" ( + python manage.py createsuperuser +) + +echo. +echo ================================ +echo ✅ Setup complete! +echo. +echo 📝 Next steps: +echo 1. Edit .env with your API keys: +echo - OPENCLAW_API_KEY +echo - HRFLOW_API_KEY +echo - HRFLOW_SOURCE_KEY +echo. +echo 2. Start the server: +echo python manage.py runserver +echo. +echo 3. Visit http://localhost:8000 +echo. +pause diff --git a/apx-cv-killer/assets/setup.sh b/apx-cv-killer/assets/setup.sh new file mode 100644 index 0000000..e66f5b3 --- /dev/null +++ b/apx-cv-killer/assets/setup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# CV Killer - Quick Setup Script + +echo "🚀 CV Killer - Setup Script" +echo "================================" + +# Check Python version +python_version=$(python --version 2>&1 | awk '{print $2}') +echo "✓ Python version: $python_version" + +# Create virtual environment +echo "" +echo "📦 Creating virtual environment..." +python -m venv venv + +# Activate virtual environment +echo "🔌 Activating virtual environment..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + source venv/Scripts/activate +else + source venv/bin/activate +fi + +# Install dependencies +echo "" +echo "⬇️ Installing dependencies..." +pip install -r requirements.txt + +# Setup environment +echo "" +echo "⚙️ Setting up environment..." +if [ ! -f .env ]; then + cp .env.example .env + echo "✓ Created .env file - IMPORTANT: Update with your API keys!" +else + echo "✓ .env file already exists" +fi + +# Run migrations +echo "" +echo "🗄️ Running database migrations..." +python manage.py migrate + +# Create superuser (optional) +echo "" +echo "👤 Create superuser? (y/n)" +read -r create_superuser +if [[ $create_superuser == "y" || $create_superuser == "Y" ]]; then + python manage.py createsuperuser +fi + +echo "" +echo "================================" +echo "✅ Setup complete!" +echo "" +echo "📝 Next steps:" +echo "1. Edit .env with your API keys:" +echo " - OPENCLAW_API_KEY" +echo " - HRFLOW_API_KEY" +echo " - HRFLOW_SOURCE_KEY" +echo "" +echo "2. Start the server:" +echo " python manage.py runserver" +echo "" +echo "3. Visit http://localhost:8000" +echo "" diff --git a/apx-cv-killer/assets/sourcing/__init__.py b/apx-cv-killer/assets/sourcing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apx-cv-killer/assets/sourcing/admin.py b/apx-cv-killer/assets/sourcing/admin.py new file mode 100644 index 0000000..3a63e69 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from .models import JobOffer, SearchSession, CandidateProfile, CandidateScore + + +@admin.register(JobOffer) +class JobOfferAdmin(admin.ModelAdmin): + list_display = ('title', 'created_at', 'updated_at') + search_fields = ('title', 'description') + readonly_fields = ('created_at', 'updated_at') + + +@admin.register(SearchSession) +class SearchSessionAdmin(admin.ModelAdmin): + list_display = ('job_offer', 'status', 'openclaw_results_count', 'created_at') + list_filter = ('status', 'created_at') + search_fields = ('job_offer__title', 'search_query') + readonly_fields = ('created_at', 'updated_at') + + +@admin.register(CandidateProfile) +class CandidateProfileAdmin(admin.ModelAdmin): + list_display = ('candidate_name', 'source_name', 'source_url', 'created_at') + list_filter = ('source_name', 'created_at') + search_fields = ('candidate_name', 'source_url') + readonly_fields = ('created_at', 'raw_data') + + +@admin.register(CandidateScore) +class CandidateScoreAdmin(admin.ModelAdmin): + list_display = ('candidate', 'hrflow_score', 'match_percentage', 'user_feedback') + list_filter = ('user_feedback', 'hrflow_score') + search_fields = ('candidate__candidate_name',) + readonly_fields = ('created_at', 'updated_at') diff --git a/apx-cv-killer/assets/sourcing/apps.py b/apx-cv-killer/assets/sourcing/apps.py new file mode 100644 index 0000000..f9dc054 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SourcingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'sourcing' + verbose_name = 'Candidate Sourcing' diff --git a/apx-cv-killer/assets/sourcing/github_scraping.py b/apx-cv-killer/assets/sourcing/github_scraping.py new file mode 100644 index 0000000..f2232ba --- /dev/null +++ b/apx-cv-killer/assets/sourcing/github_scraping.py @@ -0,0 +1,212 @@ +import requests +from dotenv import load_dotenv +import os +# Charger les variables +load_dotenv() + +GITHUB_TOKEN = "" + +def get_github_profiles(query, per_page=100, max_pages=10, token=None): + """Retourne une liste d'utilisateurs GitHub correspondant à une query de recherche. + + - query : chaîne de recherche GitHub (`location:Paris language:Python` etc.). + - per_page : nombre de résultats par page (max 100). + - max_pages : nombre maxi de pages à collecter. + - token : token GitHub (recommandé pour éviter les limitations de rate limit). + """ + + if per_page <= 0 or per_page > 100: + raise ValueError("per_page doit être entre 1 et 100") + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + all_users = [] + for page in range(1, max_pages + 1): + resp = requests.get( + "https://api.github.com/search/users", + params={"q": query, "per_page": per_page, "page": page}, + headers=headers, + ) + + resp.raise_for_status() + data = resp.json() + + items = data.get("items", []) + if not items: + break + + all_users.extend(items) + + # GitHub Search API limite à ~1000 résultats + if len(items) < per_page: + break + + return all_users + + +def get_github_profiles_one_page(query, token=None): + """Retourne jusqu'à 100 profils GitHub dans une seule requête.""" + return get_github_profiles(query, per_page=100, max_pages=1, token=token) + + +def _get_readme_from_repo(owner, repo, token=None): + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + resp = requests.get(f"https://api.github.com/repos/{owner}/{repo}/readme", headers=headers) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + content = data.get("content") + if not content: + return None + + import base64 + + try: + decoded = base64.b64decode(content).decode("utf-8", errors="replace") + except Exception: + return None + return decoded + + +def get_github_user_profile(username, token=None): + """Retourne le profil public GitHub d'un utilisateur par son login.""" + if not username: + raise ValueError("username est requis") + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + resp = requests.get(f"https://api.github.com/users/{username}", headers=headers) + resp.raise_for_status() + return resp.json() + + +def get_github_candidate_summary(username, token=None): + """Retourne un dictionnaire avec les informations du profil GitHub. + + Contenu: + - informations principales (nom, login, url, bio, etc.) + - readme de profil utilisateur + - readme des 3 plus gros repos si `hireable`=true, sinon None. + - text: le résumé complet en texte + """ + + profile = get_github_user_profile(username, token=token) + + profile_info = {} + profile_info['name'] = profile.get('name') or 'N/A' + profile_info['login'] = profile.get('login') + profile_info['url'] = profile.get('html_url') + profile_info['bio'] = profile.get('bio') or 'N/A' + profile_info['company'] = profile.get('company') or 'N/A' + profile_info['location'] = profile.get('location') or 'N/A' + profile_info['blog'] = profile.get('blog') or 'N/A' + profile_info['email'] = profile.get('email') or 'N/A' + profile_info['hireable'] = profile.get('hireable') + profile_info['public_repos'] = profile.get('public_repos') + profile_info['followers'] = profile.get('followers') + profile_info['following'] = profile.get('following') + profile_info['created_at'] = profile.get('created_at') + + profile_readme = _get_readme_from_repo(username, username, token=token) + profile_info['profile_readme'] = profile_readme or 'Aucun README de profil trouvé' + + if not profile.get("hireable"): + return None + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + repo_resp = requests.get( + f"https://api.github.com/users/{username}/repos", + params={"sort": "size", "direction": "desc", "per_page": 100}, + headers=headers, + ) + repo_resp.raise_for_status() + repos = repo_resp.json() or [] + top_repos = repos[:3] + + profile_info['top_repos'] = [] + for repo in top_repos: + repo_name = repo.get("name") + readme = _get_readme_from_repo(username, repo_name, token=token) + repo_info = { + 'name': repo_name, + 'url': repo.get('html_url'), + 'size': repo.get('size'), + 'readme': readme or 'Aucun README trouvé pour ce dépôt' + } + profile_info['top_repos'].append(repo_info) + + # Build the text summary + summary_lines = [] + summary_lines.append(f"Nom: {profile_info['name']}") + summary_lines.append(f"Login: {profile_info['login']}") + summary_lines.append(f"URL: {profile_info['url']}") + summary_lines.append(f"Bio: {profile_info['bio']}") + summary_lines.append(f"Entreprise: {profile_info['company']}") + summary_lines.append(f"Localisation: {profile_info['location']}") + summary_lines.append(f"Site Web: {profile_info['blog']}") + summary_lines.append(f"Email: {profile_info['email']}") + summary_lines.append(f"Hireable: {profile_info['hireable']}") + summary_lines.append(f"Public repos: {profile_info['public_repos']}") + summary_lines.append(f"Followers: {profile_info['followers']}, Following: {profile_info['following']}") + summary_lines.append(f"Créé le: {profile_info['created_at']}") + + summary_lines.append("\n=== README UTILISATEUR ===") + summary_lines.append(profile_info['profile_readme']) + + summary_lines.append("\n=== README DES 3 PLUS GROS PROJETS ===") + + if not profile_info['top_repos']: + summary_lines.append("Aucun dépôt utilisateur trouvé") + else: + for repo in profile_info['top_repos']: + summary_lines.append(f"-- {repo['name']} ({repo['url']}) taille={repo['size']}") + summary_lines.append(repo['readme']) + + profile_info['text'] = "\n".join(summary_lines) + + return profile_info + + +if __name__ == "__main__": + import sys + from pathlib import Path + BASE_DIR = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(BASE_DIR)) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cv_killer.settings') + from hrflow_service import HrFlowService + + service = HrFlowService() + + # Récupérer des profils GitHub + users = get_github_profiles_one_page("location:Paris language:Python", token=GITHUB_TOKEN) + print(f"Trouvé {len(users)} utilisateurs") + + for user in users[:22]: # Limiter à 5 pour le test + login = user.get("login") + print(f"Traitement de {login}") + + # Obtenir le résumé + summary = get_github_candidate_summary(login, token=GITHUB_TOKEN) + + if summary and summary.get('hireable'): + print(f"{login} est hireable") + + # Appliquer parse_profile et envoyer à l'API + try: + result = service.parse_profile(login, summary) + print(f"Profil indexé pour {login}: {result}") + except Exception as e: + print(f"Erreur lors de l'indexation de {login}: {e}") + else: + print(f"{login} n'est pas hireable ou pas de résumé") diff --git a/apx-cv-killer/assets/sourcing/hrflow_service.py b/apx-cv-killer/assets/sourcing/hrflow_service.py new file mode 100644 index 0000000..6369d15 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/hrflow_service.py @@ -0,0 +1,224 @@ +import os +import json +import tempfile +import logging +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +class HrFlowService: + def __init__(self): + self.base_url = getattr(settings, 'HRFLOW_BASE_URL', 'https://api.hrflow.ai/v1').rstrip('/') + self.api_key = settings.HRFLOW_API_KEY + self.source_key = settings.HRFLOW_SOURCE_KEY + self.board_key = settings.HRFLOW_BOARD_KEY + self.user_email = getattr(settings, 'HRFLOW_USER_EMAIL', '') + self.algorithm_key = getattr(settings, 'HRFLOW_ALGORITHM_KEY', '') + + def _get_headers(self, is_json=False): + """Builds headers safely, omitting blank emails to prevent 'Invalid Role' errors.""" + headers = { + "X-API-KEY": self.api_key + } + + # FIX: Only attach the email header if it actually exists! + if self.user_email and self.user_email.strip(): + headers["X-USER-EMAIL"] = self.user_email.strip() + + if is_json: + headers["Content-Type"] = "application/json" + + return headers + + def _handle_error(self, response): + """A bulletproof error handler that will never crash on weird server responses.""" + if response.status_code == 202: + return + + # 1. Check for hard HTTP errors first (400, 401, 404, 500) + if not response.ok: + try: + err_msg = json.dumps(response.json()) + except: + err_msg = response.text or str(response.status_code) + raise Exception(f"HTTP {response.status_code}: {err_msg}") + + # 2. Check for sneaky HrFlow errors disguised as 200 OK + try: + data = response.json() + if isinstance(data, dict): + code = str(data.get('code', '200')) + if code.startswith(('4', '5')): + raise Exception(f"HrFlow Internal Error: {json.dumps(data)}") + except Exception: + pass # Not a JSON response, or literal '0', safely ignore + + # ------------------------------------------------------------------------- + # PUBLIC API + # ------------------------------------------------------------------------- + + def index_job(self, reference, raw_text: str): + """ + Uses the Text Parsing API to extract structured job data (title, skills, sections) + from raw text, then feeds it directly into the Job Indexing API. + """ + # --------------------------------------------------------- + # 1. TEXT PARSING (Extracting the structured job data) + # --------------------------------------------------------- + parse_endpoint = f"{self.base_url}/text/parsing" + + # We tell HrFlow to treat this text as a Job and format it accordingly + parse_payload = { + "texts": [str(raw_text)], + "output_object": "job" + } + + parse_response = requests.post(parse_endpoint, headers=self._get_headers(is_json=True), json=parse_payload) + self._handle_error(parse_response) + + # The AI returns a list of parsed objects; we grab the first one + parsed_data = parse_response.json().get("data", [{}])[0] + job_parsed = parsed_data.get("job", {}) + + # Fallback to ensure the strict database validator doesn't crash if + # the AI somehow couldn't deduce a clear job title from the text. + if not job_parsed.get("name"): + job_parsed["name"] = "Untitled Parsed Job" + job_parsed["reference"] = str(reference) + + # --------------------------------------------------------- + # 2. JOB INDEXING (Saving it to the Board) + # --------------------------------------------------------- + index_endpoint = f"{self.base_url}/job/indexing" + + index_payload = { + "board_key": self.board_key, + "job": { + **job_parsed + } + } + + index_response = requests.post(index_endpoint, headers=self._get_headers(is_json=True), json=index_payload) + self._handle_error(index_response) + + logger.info(f"Successfully parsed and indexed Job: {reference}") + return index_response.json() + + def parse_profile(self, reference, raw_text:str, file_path=None, source_url=""): + """ + Hackathon Bypass: Since the free tier blocks synchronous AI Document Parsing, + we use the Text Parsing API to extract structured data (skills, experiences), + and then feed that directly into the instant Profile Indexing API! + """ + if not raw_text and file_path: + try: + with open(file_path, 'r', encoding='utf-8') as f: + profile_data = f.read() + except: + raw_text = "See attached file." + + # --------------------------------------------------------- + # 1. TEXT PARSING (Extracting the structured data) + # --------------------------------------------------------- + parse_endpoint = f"{self.base_url}/text/parsing" + + # We tell HrFlow to treat this text as a Profile and format it accordingly + parse_payload = { + "texts": [str(raw_text)], + "output_object": "profile" + } + + parse_response = requests.post(parse_endpoint, headers=self._get_headers(is_json=True), json=parse_payload) + self._handle_error(parse_response) + + # The AI returns a list of parsed objects (one for each text we sent) + parsed_data = parse_response.json().get("data", [{}])[0] + + # --------------------------------------------------------- + # 2. PROFILE INDEXING (Saving it to the database) + # --------------------------------------------------------- + index_endpoint = f"{self.base_url}/profile/indexing" + + index_payload = { + "source_key": self.source_key, + "profile": { + "reference": str(reference), + **parsed_data["profile"] + } + } + + index_response = requests.post(index_endpoint, headers=self._get_headers(is_json=True), json=index_payload) + self._handle_error(index_response) + + logger.info(f"Successfully parsed and instantly indexed Profile: {reference}") + return index_response.json() + + def get_profile(self, reference): + """Actively checks if a profile exists in the database.""" + endpoint = f"{self.base_url}/profile/indexing" + params = { + 'source_key': self.source_key, + 'reference': str(reference) + } + response = requests.get(endpoint, headers=self._get_headers(), params=params) + + # If it returns 200 OK, the queue is finished and the profile is ready! + if response.status_code == 200: + data = response.json() + if str(data.get('code', '200')) == '200': + return data.get('data') + return None + + def get_candidate_scores(self, job_reference): + endpoint = f"{self.base_url}/profiles/grading" + params = { + 'algorithm_key': "grader-hrflow-jobs", + 'source_key': self.source_key, + 'board_key': self.board_key, + 'job_reference': str(job_reference) + } + + if self.algorithm_key: + params['algorithm_key'] = self.algorithm_key + + response = requests.get(endpoint, headers=self._get_headers(is_json=True), params=params) + self._handle_error(response) + + raw_data = response.json() + print(f"\n🔍 DEBUG API PAYLOAD: {json.dumps(raw_data)[:300]}...\n") + + data = raw_data.get('data', []) + + # Flatten HrFlow's nested dictionary structures into a standard list + if isinstance(data, dict): + # Check for standard wrapper keys + for wrapper_key in ['predictions', 'profiles', 'matches']: + if wrapper_key in data and isinstance(data[wrapper_key], list): + data = data[wrapper_key] + break + + # If it's a nested list of lists (common in their ML endpoints), grab the first array + if isinstance(data, list) and len(data) > 0 and isinstance(data[0], list): + data = data[0] + + # If it is STILL a dictionary, wrap it in a list so scores[0] doesn't throw KeyError: 0 + if isinstance(data, dict): + data = [data] + + return data + + def send_rating(self, job_ref, profile_ref, is_shortlisted, comment=""): + endpoint = f"{self.base_url}/rating" + payload = { + "source_keys": [self.source_key], + "board_key": self.board_key, + "job_reference": str(job_ref), + "profile_reference": str(profile_ref), + "rating": 1 if is_shortlisted else 0, + "message": comment + } + response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) + self._handle_error(response) + return response.json() + diff --git a/apx-cv-killer/assets/sourcing/migrations/0001_initial.py b/apx-cv-killer/assets/sourcing/migrations/0001_initial.py new file mode 100644 index 0000000..d0d5d05 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2 on 2026-03-28 08:52 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CandidateProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source_url', models.URLField()), + ('source_name', models.CharField(max_length=255)), + ('candidate_name', models.CharField(blank=True, max_length=255)), + ('raw_data', models.JSONField()), + ('hrflow_profile_id', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='JobOffer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField()), + ('file', models.FileField(upload_to='job_offers/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'txt', 'docx'])])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Job Offer', + 'verbose_name_plural': 'Job Offers', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SearchSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('search_query', models.TextField()), + ('openclaw_results_count', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('job_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='search_sessions', to='sourcing.joboffer')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CandidateScore', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hrflow_score', models.FloatField()), + ('match_percentage', models.FloatField(default=0)), + ('skills_match', models.JSONField(default=dict)), + ('experience_match', models.JSONField(default=dict)), + ('user_feedback', models.CharField(choices=[('relevant', 'Relevant'), ('irrelevant', 'Irrelevant'), ('maybe', 'Maybe'), ('pending', 'Pending')], default='pending', max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('candidate', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='score', to='sourcing.candidateprofile')), + ], + options={ + 'verbose_name_plural': 'Candidate Scores', + 'ordering': ['-hrflow_score', '-match_percentage'], + }, + ), + migrations.AddField( + model_name='candidateprofile', + name='search_session', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='sourcing.searchsession'), + ), + ] diff --git a/apx-cv-killer/assets/sourcing/migrations/__init__.py b/apx-cv-killer/assets/sourcing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apx-cv-killer/assets/sourcing/models.py b/apx-cv-killer/assets/sourcing/models.py new file mode 100644 index 0000000..96cf719 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/models.py @@ -0,0 +1,91 @@ +from django.db import models +from django.core.validators import FileExtensionValidator + + +class JobOffer(models.Model): + """Model to store uploaded job offers""" + title = models.CharField(max_length=255) + description = models.TextField() + file = models.FileField( + upload_to='job_offers/', + validators=[FileExtensionValidator(allowed_extensions=['pdf', 'txt', 'docx'])] + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Job Offer' + verbose_name_plural = 'Job Offers' + + def __str__(self): + return self.title + + +class SearchSession(models.Model): + """Model to track a search session for a job offer""" + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('running', 'Running'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ] + + job_offer = models.ForeignKey(JobOffer, on_delete=models.CASCADE, related_name='search_sessions') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + search_query = models.TextField() # Extracted or user-provided search query + openclaw_results_count = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + error_message = models.TextField(blank=True, null=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"Search for {self.job_offer.title} - {self.status}" + + +class CandidateProfile(models.Model): + """Model to store candidate profiles found via Openclaw""" + search_session = models.ForeignKey(SearchSession, on_delete=models.CASCADE, related_name='candidates') + source_url = models.URLField() + source_name = models.CharField(max_length=255) # LinkedIn, GitHub, Portfolio, etc. + candidate_name = models.CharField(max_length=255, blank=True) + raw_data = models.JSONField() # Store raw data from Openclaw + hrflow_profile_id = models.CharField(max_length=255, blank=True, null=True) # Reference to HrFlow profile + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.candidate_name} - {self.source_name}" + + +class CandidateScore(models.Model): + """Model to store HrFlow scoring and grading for candidates""" + candidate = models.OneToOneField(CandidateProfile, on_delete=models.CASCADE, related_name='score') + hrflow_score = models.FloatField() # Score from HrFlow API (0-100) + match_percentage = models.FloatField(default=0) # Match percentage to job requirements + skills_match = models.JSONField(default=dict) # Matched skills + experience_match = models.JSONField(default=dict) # Experience match + user_feedback = models.CharField( + max_length=50, + choices=[ + ('relevant', 'Relevant'), + ('irrelevant', 'Irrelevant'), + ('maybe', 'Maybe'), + ('pending', 'Pending') + ], + default='pending' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-hrflow_score', '-match_percentage'] + verbose_name_plural = 'Candidate Scores' + + def __str__(self): + return f"{self.candidate.candidate_name} - {self.hrflow_score}/100" diff --git a/apx-cv-killer/assets/sourcing/requete_openclaw.py b/apx-cv-killer/assets/sourcing/requete_openclaw.py new file mode 100644 index 0000000..3abcff5 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/requete_openclaw.py @@ -0,0 +1,72 @@ +import os +import json +import paramiko +from dotenv import load_dotenv + +# Charger les variables +load_dotenv() + +SSH_HOST = "192.168.0.25" +SSH_USER = "hackathon-team2" +SSH_PASS = os.getenv("SSH_PASSWORD") +TOKEN = os.getenv("OPENCLAW_GATEWAY_TOKEN") + +def ask_openclaw_direct(prompt): + # 1. Initialisation du client SSH + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + # 2. Connexion automatisée : on force le mdp et on interdit les clés publiques ! + client.connect( + SSH_HOST, + username=SSH_USER, + password=SSH_PASS, + look_for_keys=False, # Règle le problème du PubkeyAuthentication=no + allow_agent=False + ) + + # 3. Préparation des données JSON + payload_str = json.dumps({ + "model": "openclaw:main", + "messages": [{"role": "user", "content": prompt}] + }) + + # 4. On demande au Mac Mini de faire le curl lui-même (via stdin pour éviter les soucis de guillemets) + curl_cmd = f'curl -s -X POST "http://127.0.0.1:18789/v1/chat/completions" ' \ + f'-H "Content-Type: application/json" ' \ + f'-H "Authorization: Bearer {TOKEN}" ' \ + f'-d @-' + + stdin, stdout, stderr = client.exec_command(curl_cmd) + + # On envoie le JSON et on ferme l'entrée + stdin.write(payload_str) + stdin.channel.shutdown_write() + + # 5. Récupération et parsing de la réponse + raw_output = stdout.read().decode('utf-8') + + if not raw_output: + error_msg = stderr.read().decode('utf-8') + return f"Erreur cURL distante : {error_msg}" + + response_json = json.loads(raw_output) + return response_json['choices'][0]['message']['content'] + + except paramiko.AuthenticationException: + return "❌ Erreur : Mot de passe incorrect ou refusé." + except Exception as e: + return f"❌ Erreur inattendue : {e}" + finally: + client.close() + +# --- MAIN --- +if __name__ == "__main__": + print("🔌 Connexion SSH en cours d'arrière-plan...") + question = "Peux-tu me confirmer que cette exécution distante 100% automatisée fonctionne ?" + + print("⏳ Envoi de la requête à OpenClaw...") + reponse = ask_openclaw_direct(question) + + print(f"\n🤖 Réponse d'OpenClaw :\n{reponse}") \ No newline at end of file diff --git a/apx-cv-killer/assets/sourcing/services.py b/apx-cv-killer/assets/sourcing/services.py new file mode 100644 index 0000000..a11b664 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/services.py @@ -0,0 +1,258 @@ +import requests +import logging +from django.conf import settings +from .models import CandidateProfile, CandidateScore, SearchSession + +logger = logging.getLogger(__name__) + + +class OpenclawService: + """Service to handle Openclaw API calls for web scraping""" + + def __init__(self): + self.api_key = settings.OPENCLAW_API_KEY + self.base_url = settings.OPENCLAW_BASE_URL + self.headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + } + + def search_profiles(self, query, search_session): + """ + Search for candidate profiles across web sources using Openclaw + + Args: + query (str): Search query based on job offer + search_session (SearchSession): The search session to associate results with + + Returns: + list: List of candidate profiles found + """ + try: + # TODO: Implement actual Openclaw API call + # This is a placeholder for the integration + endpoint = f"{self.base_url}/search" + payload = { + 'query': query, + 'sources': ['linkedin', 'github', 'portfolios', 'cv_databases'], + 'limit': settings.MAX_RESULTS, + } + + response = requests.post(endpoint, json=payload, headers=self.headers, timeout=30) + response.raise_for_status() + + results = response.json().get('results', []) + logger.info(f"Openclaw search returned {len(results)} results for query: {query}") + + return results + + except requests.exceptions.RequestException as e: + error_msg = f"Openclaw API error: {str(e)}" + logger.error(error_msg) + search_session.status = 'failed' + search_session.error_message = error_msg + search_session.save() + raise + + +class HrFlowService: + """Service to handle HrFlow.ai API calls for parsing and scoring candidates""" + + def __init__(self): + self.api_key = settings.HRFLOW_API_KEY + self.source_key = settings.HRFLOW_SOURCE_KEY + self.base_url = settings.HRFLOW_BASE_URL + self.headers = { + 'X-API-KEY': self.api_key, + 'Content-Type': 'application/json', + } + + def parse_profile(self, profile_data): + """ + Parse a candidate profile using HrFlow API + + Args: + profile_data (dict): Raw profile data from Openclaw + + Returns: + dict: Parsed profile data with standardized fields + """ + try: + # TODO: Implement HrFlow profile parsing API call + endpoint = f"{self.base_url}/profiles/parsing" + payload = { + 'source_key': self.source_key, + 'data': profile_data, + } + + response = requests.post(endpoint, json=payload, headers=self.headers, timeout=30) + response.raise_for_status() + + parsed_data = response.json() + logger.info(f"HrFlow parsed profile successfully") + + return parsed_data + + except requests.exceptions.RequestException as e: + logger.error(f"HrFlow parsing error: {str(e)}") + raise + + def score_candidate(self, job_description, candidate_profile): + """ + Score a candidate against job requirements using HrFlow API + + Args: + job_description (str): Job description/requirements + candidate_profile (dict): Parsed candidate profile + + Returns: + dict: Scoring results with match percentage and details + """ + try: + # TODO: Implement HrFlow scoring API call + endpoint = f"{self.base_url}/profiles/scoring" + payload = { + 'source_key': self.source_key, + 'job_description': job_description, + 'candidate': candidate_profile, + } + + response = requests.post(endpoint, json=payload, headers=self.headers, timeout=30) + response.raise_for_status() + + score_data = response.json() + logger.info(f"HrFlow scored candidate") + + return score_data + + except requests.exceptions.RequestException as e: + logger.error(f"HrFlow scoring error: {str(e)}") + raise + + def search_profiles(self, query): + """ + Search existing profiles in HrFlow using a query + + Args: + query (str): Search query + + Returns: + list: Matching profiles from HrFlow + """ + try: + endpoint = f"{self.base_url}/profiles/searching" + payload = { + 'source_key': self.source_key, + 'name': query, + } + + response = requests.get(endpoint, params=payload, headers=self.headers, timeout=30) + response.raise_for_status() + + profiles = response.json().get('data', {}).get('profiles', []) + logger.info(f"HrFlow search returned {len(profiles)} profiles") + + return profiles + + except requests.exceptions.RequestException as e: + logger.error(f"HrFlow search error: {str(e)}") + raise + + +class SourcingService: + """Main service orchestrating the sourcing workflow""" + + def __init__(self): + self.openclaw = OpenclawService() + self.hrflow = HrFlowService() + + def process_job_offer(self, job_offer, search_session): + """ + Main workflow: search, parse, score and rank candidates + + Args: + job_offer (JobOffer): The job offer object + search_session (SearchSession): The search session to track + """ + try: + # Step 1: Extract search query from job offer + search_query = self._extract_search_query(job_offer) + search_session.search_query = search_query + search_session.status = 'running' + search_session.save() + + # Step 2: Search using Openclaw + openclaw_results = self.openclaw.search_profiles(search_query, search_session) + search_session.openclaw_results_count = len(openclaw_results) + search_session.save() + + # Step 3: Process each result - parse and score + for result in openclaw_results[:settings.MAX_RESULTS]: + self._process_candidate(result, job_offer.description, search_session) + + # Step 4: Mark session as completed + search_session.status = 'completed' + search_session.save() + + logger.info(f"Successfully processed {len(openclaw_results)} candidates for job {job_offer.id}") + + except Exception as e: + logger.error(f"Error processing job offer: {str(e)}") + search_session.status = 'failed' + search_session.error_message = str(e) + search_session.save() + raise + + def _extract_search_query(self, job_offer): + """ + Extract a search query from job offer description + In a real implementation, use NLP/summarization + + Args: + job_offer (JobOffer): The job offer + + Returns: + str: Search query string + """ + # TODO: Implement smart query extraction using NLP + # For now, return first 50 chars of title + return job_offer.title + + def _process_candidate(self, candidate_data, job_description, search_session): + """ + Process a single candidate: create profile, parse, score + + Args: + candidate_data (dict): Raw data from Openclaw + job_description (str): Job description for scoring + search_session (SearchSession): The search session + """ + try: + # Create candidate profile + candidate = CandidateProfile.objects.create( + search_session=search_session, + source_url=candidate_data.get('url', ''), + source_name=candidate_data.get('source', 'Unknown'), + candidate_name=candidate_data.get('name', 'Unknown'), + raw_data=candidate_data, + ) + + # Parse using HrFlow + parsed_profile = self.hrflow.parse_profile(candidate_data) + + # Score using HrFlow + score_data = self.hrflow.score_candidate(job_description, parsed_profile) + + # Create score record + CandidateScore.objects.create( + candidate=candidate, + hrflow_score=score_data.get('score', 0), + match_percentage=score_data.get('match_percentage', 0), + skills_match=score_data.get('skills', {}), + experience_match=score_data.get('experience', {}), + ) + + logger.info(f"Successfully processed candidate {candidate.candidate_name}") + + except Exception as e: + logger.error(f"Error processing candidate: {str(e)}") diff --git a/apx-cv-killer/assets/sourcing/urls.py b/apx-cv-killer/assets/sourcing/urls.py new file mode 100644 index 0000000..1adf4e7 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='index'), + path('search//', views.search_results, name='search_results'), + path('candidate//', views.candidate_detail, name='candidate_detail'), + path('candidate//feedback/', views.update_feedback, name='update_feedback'), + path('session//status/', views.session_status, name='session_status'), +] diff --git a/apx-cv-killer/assets/sourcing/views.py b/apx-cv-killer/assets/sourcing/views.py new file mode 100644 index 0000000..2afa330 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/views.py @@ -0,0 +1,125 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.views.decorators.http import require_http_methods +from django.contrib import messages +from django.core.paginator import Paginator +from .models import JobOffer, SearchSession, CandidateProfile, CandidateScore +from .services import SourcingService +import logging +from threading import Thread + +logger = logging.getLogger(__name__) + + +def index(request): + """Main page with job offer upload and search results""" + context = {} + + if request.method == 'POST': + title = request.POST.get('title', '') + description = request.POST.get('description', '') + file = request.FILES.get('file') + + if not title or not description: + messages.error(request, 'Title and description are required.') + return render(request, 'sourcing/index.html', context) + + # Create job offer + job_offer = JobOffer.objects.create( + title=title, + description=description, + file=file if file else None, + ) + + # Create search session + search_session = SearchSession.objects.create( + job_offer=job_offer, + search_query=title, + ) + + # Start sourcing process in background thread + sourcing_service = SourcingService() + thread = Thread( + target=sourcing_service.process_job_offer, + args=(job_offer, search_session), + daemon=True + ) + thread.start() + + messages.success(request, f'Job offer created! Searching for candidates...') + return redirect('search_results', search_session_id=search_session.id) + + # Show recent job offers + context['recent_jobs'] = JobOffer.objects.all()[:5] + context['total_searches'] = SearchSession.objects.count() + + return render(request, 'sourcing/index.html', context) + + +def search_results(request, search_session_id): + """Display search results and candidate rankings""" + search_session = get_object_or_404(SearchSession, id=search_session_id) + + # Get candidates with scores, ranked + candidates = ( + CandidateProfile.objects + .filter(search_session=search_session) + .select_related('score') + .order_by('-score__hrflow_score', '-score__match_percentage') + ) + + # Pagination + paginator = Paginator(candidates, 10) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + context = { + 'search_session': search_session, + 'page_obj': page_obj, + 'candidates': page_obj.object_list, + 'total_candidates': candidates.count(), + } + + return render(request, 'sourcing/results.html', context) + + +@require_http_methods(["POST"]) +def update_feedback(request, candidate_id): + """Update user feedback for a candidate""" + candidate = get_object_or_404(CandidateProfile, id=candidate_id) + feedback = request.POST.get('feedback') + + if feedback in ['relevant', 'irrelevant', 'maybe']: + candidate.score.user_feedback = feedback + candidate.score.save() + messages.success(request, 'Feedback recorded!') + else: + messages.error(request, 'Invalid feedback value.') + + return redirect('search_results', search_session_id=candidate.search_session.id) + + +def candidate_detail(request, candidate_id): + """Show detailed view of a single candidate""" + candidate = get_object_or_404(CandidateProfile, id=candidate_id) + + context = { + 'candidate': candidate, + 'score': candidate.score if hasattr(candidate, 'score') else None, + } + + return render(request, 'sourcing/candidate_detail.html', context) + + +def session_status(request, search_session_id): + """API endpoint to check search session status (for AJAX polls)""" + from django.http import JsonResponse + + search_session = get_object_or_404(SearchSession, id=search_session_id) + candidates_count = search_session.candidates.count() + + return JsonResponse({ + 'status': search_session.status, + 'openclaw_results_count': search_session.openclaw_results_count, + 'candidates_processed': candidates_count, + 'error_message': search_session.error_message, + }) diff --git a/apx-cv-killer/assets/templates/base.html b/apx-cv-killer/assets/templates/base.html new file mode 100644 index 0000000..d47b71f --- /dev/null +++ b/apx-cv-killer/assets/templates/base.html @@ -0,0 +1,251 @@ + + + + + + {% block title %}CV Killer - AI Sourcing Agent{% endblock %} + + + {% block extra_css %}{% endblock %} + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
+ + + + + {% block extra_js %}{% endblock %} + + diff --git a/apx-cv-killer/assets/templates/sourcing/candidate_detail.html b/apx-cv-killer/assets/templates/sourcing/candidate_detail.html new file mode 100644 index 0000000..ca74d16 --- /dev/null +++ b/apx-cv-killer/assets/templates/sourcing/candidate_detail.html @@ -0,0 +1,128 @@ +{% extends 'base.html' %} + +{% block title %}{{ candidate.candidate_name }} - CV Killer{% endblock %} + +{% block content %} +
+
+ + ← Back to Results + + +
+ +
+
+
+

{{ candidate.candidate_name }}

+ +
+
+

Source Platform

+

{{ candidate.source_name }}

+
+ +
+ +
+ +

Profile Data

+
+
{{ candidate.raw_data|safe }}
+
+
+
+
+ + +
+ {% if score %} +
+
+
HrFlow Score
+ +
+
+ {{ score.hrflow_score|floatformat:1 }} +
+
+ out of 100 +
+
+ +
+ +
+

Match Percentage

+
+
+ {{ score.match_percentage|floatformat:0 }}% +
+
+
+ +
+ +
+

Your Feedback

+
+ {% csrf_token %} + +
+
+ +
+ + {% if score.skills_match %} +
+

Matched Skills

+
+ {% for skill, level in score.skills_match.items %} + + {{ skill }} + + {% endfor %} +
+
+ {% endif %} + + +
+
+ {% else %} +
+

Score data not yet available.

+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/apx-cv-killer/assets/templates/sourcing/index.html b/apx-cv-killer/assets/templates/sourcing/index.html new file mode 100644 index 0000000..5c736e7 --- /dev/null +++ b/apx-cv-killer/assets/templates/sourcing/index.html @@ -0,0 +1,119 @@ +{% extends 'base.html' %} + +{% block title %}Upload Job Offer - CV Killer{% endblock %} + +{% block content %} +
+
+

Find Top Candidates

+ +
+
+

📋 Upload Job Offer

+
+ {% csrf_token %} + +
+ + +
+ +
+ + + Include responsibilities, requirements, and skills needed. +
+ +
+ +
+ + +
+
+ + +
+
+
+ + {% if recent_jobs %} +
+

📁 Recent Searches

+
+ {% for job in recent_jobs %} +
+
+
+
{{ job.title }}
+

{{ job.description|truncatewords:20 }}

+
+ {{ job.created_at|date:"M d, Y" }} + {% if job.search_sessions.count %} + View Results + {% endif %} +
+
+
+
+ {% endfor %} +
+
+ {% endif %} + +
+

+ {{ total_searches }} searches completed • Find top candidates across LinkedIn, GitHub, and more +

+
+
+
+ + +{% endblock %} diff --git a/apx-cv-killer/assets/templates/sourcing/results.html b/apx-cv-killer/assets/templates/sourcing/results.html new file mode 100644 index 0000000..24d11a2 --- /dev/null +++ b/apx-cv-killer/assets/templates/sourcing/results.html @@ -0,0 +1,207 @@ +{% extends 'base.html' %} + +{% block title %}Search Results - CV Killer{% endblock %} + +{% block content %} +
+
+
+
+

{{ search_session.job_offer.title }}

+

+ + {{ search_session.get_status_display }} + +

+
+ ← Back to Upload +
+ + +
+
+
+
+
+
{{ search_session.openclaw_results_count }}
+
Profiles Found
+
+
+
+
+
{{ candidates|length }}
+
Processed
+
+
+
+
+
{{ search_session.created_at|date:"M d, Y" }}
+
Search Date
+
+
+
+
+ {% if search_session.status == 'running' %} +
+
Processing...
+ {% elif search_session.status == 'failed' %} +
⚠️
+
Failed
+ {% else %} +
✓
+
Complete
+ {% endif %} +
+
+
+
+
+ + {% if search_session.error_message %} +
+ Error: {{ search_session.error_message }} +
+ {% endif %} + + + {% if candidates %} +
+
+ + + + + + + + + + + + + + {% for candidate in candidates %} + + + + + + + + + + {% endfor %} + +
RankCandidate NameSourceMatch ScoreMatch %Your FeedbackActions
+ + #{{ forloop.counter }} + + + {{ candidate.candidate_name }} + + + {{ candidate.source_name }} + + +
+ {{ candidate.score.hrflow_score|floatformat:1 }}/100 +
+
+
+
+ {{ candidate.score.match_percentage|floatformat:0 }}% +
+
+
+
+ {% csrf_token %} + +
+
+ + View Profile + +
+
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} + + {% else %} +
+
🔍
+
No candidates processed yet
+

The search is running. Please check back in a moment...

+
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/apx-cv-killer/assets/tests/test_hrflow.py b/apx-cv-killer/assets/tests/test_hrflow.py new file mode 100644 index 0000000..6b77773 --- /dev/null +++ b/apx-cv-killer/assets/tests/test_hrflow.py @@ -0,0 +1,111 @@ +import os +import django +import sys +import time +from pathlib import Path + +# 1. Initialize Django Environment +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cv_killer.settings') + +try: + django.setup() +except Exception as e: + print(f"❌ Failed to load Django settings: {e}") + sys.exit(1) + +# Import the refactored service +from sourcing.hrflow_service import HrFlowService + +def run_tests(): + print("🚀 Starting Refactored HrFlow Integration Tests...\n") + + try: + service = HrFlowService() + print("✅ Service initialized successfully.") + except Exception as e: + print(f"❌ Failed to initialize service: {e}") + return + + # --- TEST 1: Index a Job (User Input Simulation) --- + job_ref = "test-job-http-012" + print("\n📝 TEST 1: AI Parsing and Indexing Job...") + try: + raw_job_text = """ + Job Title: Senior Django Developer + Location: Paris, France + + Looking for an expert Python/Django developer for a hackathon project. + Required Skills: Python, Django, REST APIs, PostgreSQL. + """ + service.index_job(reference=job_ref, raw_text=raw_job_text) + print("✅ Job parsed and indexed successfully!") + except Exception as e: + print(f"❌ Job indexing failed: {e}") + + # --- TEST 2: Parse a Profile (OpenClaw Simulation) --- + profile_ref = "test-profile-http-012" + print("\n👤 TEST 2: AI Parsing OpenClaw Scraped Profile...") + try: + raw_cv_text = """ + John Smith + john.smith@example.com + Location: Paris, France + + Experienced Backend Developer. + Skills: Python, Django, REST APIs, Docker. + """ + service.parse_profile( + reference=profile_ref, + raw_text=raw_cv_text, + source_url="https://github.com/johnsmith" + ) + print("✅ Profile text parsed and indexed successfully!") + except Exception as e: + print(f"❌ Profile text parsing failed: {e}") + + # --- TEST 3: Parse a PDF (Optional/Demo) --- + # This will only run if you have a 'test_cv.pdf' in your root folder + pdf_path = os.path.join(BASE_DIR, "test_cv.pdf") + if os.path.exists(pdf_path): + print("\n📄 TEST 3: AI Parsing a physical PDF file...") + try: + service.parse_profile(reference="pdf-test-001", file_path=pdf_path) + print("✅ PDF parsed and indexed successfully!") + except Exception as e: + print(f"❌ PDF parsing failed: {e}") + else: + print("\nℹ️ Skipping PDF test (No 'test_cv.pdf' found in root).") + + + print("\n⏳ Waiting 2 seconds for AI Vector Indexing to process the raw text...") + time.sleep(2) + # --- TEST 4: Scoring --- + print("\n🎯 TEST 4: Requesting Match Scores...") + try: + scores = service.get_candidate_scores(job_reference=job_ref) + if scores: + print(f"✅ Found {len(scores)} matches. Top Score: {scores[0].get('score') * 100:.1f}%") + else: + print("⚠️ Scoring returned 0 results. (Try increasing wait time or check Dashboard).") + except Exception as e: + print(f"❌ Scoring failed: {e}") + + # --- TEST 5: Rating Signal --- + """print("\n👎 TEST 5: Sending Rejection Signal...") + try: + service.send_rating( + job_ref=job_ref, + profile_ref=profile_ref, + is_shortlisted=False, + comment="Refactored test rejection." + ) + print("✅ Rating signal sent successfully!") + except Exception as e: + print(f"❌ Rating failed: {e}")""" + + print("\n🎉 Integration testing complete.") + +if __name__ == "__main__": + run_tests() \ No newline at end of file