From 4396905b4b56764279a1947cc1cf1898318032c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=A9tan=20D=C3=A9got-Silvestre?= Date: Sat, 28 Mar 2026 10:39:48 +0100 Subject: [PATCH 1/7] First prototype --- apx-cv-killer/.env.example | 4 + apx-cv-killer/README.md | 48 +++ apx-cv-killer/app.json | 16 + apx-cv-killer/assets/.env.example | 27 ++ apx-cv-killer/assets/.gitkeep | 0 .../assets/API_INTEGRATION_GUIDE.txt | 251 +++++++++++++++ apx-cv-killer/assets/README.md | 297 ++++++++++++++++++ apx-cv-killer/assets/cv_killer/__init__.py | 0 apx-cv-killer/assets/cv_killer/settings.py | 94 ++++++ apx-cv-killer/assets/cv_killer/urls.py | 13 + apx-cv-killer/assets/cv_killer/wsgi.py | 5 + apx-cv-killer/assets/db.sqlite3 | Bin 0 -> 163840 bytes apx-cv-killer/assets/manage.py | 22 ++ apx-cv-killer/assets/requirements.txt | 8 + apx-cv-killer/assets/setup.bat | 63 ++++ apx-cv-killer/assets/setup.sh | 67 ++++ apx-cv-killer/assets/sourcing/__init__.py | 0 apx-cv-killer/assets/sourcing/admin.py | 33 ++ apx-cv-killer/assets/sourcing/apps.py | 7 + .../sourcing/migrations/0001_initial.py | 86 +++++ .../assets/sourcing/migrations/__init__.py | 0 apx-cv-killer/assets/sourcing/models.py | 91 ++++++ .../assets/sourcing/requete_openclaw.py | 72 +++++ apx-cv-killer/assets/sourcing/services.py | 258 +++++++++++++++ apx-cv-killer/assets/sourcing/urls.py | 10 + apx-cv-killer/assets/sourcing/views.py | 125 ++++++++ apx-cv-killer/assets/templates/base.html | 251 +++++++++++++++ .../templates/sourcing/candidate_detail.html | 128 ++++++++ .../assets/templates/sourcing/index.html | 119 +++++++ .../assets/templates/sourcing/results.html | 207 ++++++++++++ 30 files changed, 2302 insertions(+) create mode 100644 apx-cv-killer/.env.example create mode 100644 apx-cv-killer/README.md create mode 100644 apx-cv-killer/app.json create mode 100644 apx-cv-killer/assets/.env.example create mode 100644 apx-cv-killer/assets/.gitkeep create mode 100644 apx-cv-killer/assets/API_INTEGRATION_GUIDE.txt create mode 100644 apx-cv-killer/assets/README.md create mode 100644 apx-cv-killer/assets/cv_killer/__init__.py create mode 100644 apx-cv-killer/assets/cv_killer/settings.py create mode 100644 apx-cv-killer/assets/cv_killer/urls.py create mode 100644 apx-cv-killer/assets/cv_killer/wsgi.py create mode 100644 apx-cv-killer/assets/db.sqlite3 create mode 100644 apx-cv-killer/assets/manage.py create mode 100644 apx-cv-killer/assets/requirements.txt create mode 100644 apx-cv-killer/assets/setup.bat create mode 100644 apx-cv-killer/assets/setup.sh create mode 100644 apx-cv-killer/assets/sourcing/__init__.py create mode 100644 apx-cv-killer/assets/sourcing/admin.py create mode 100644 apx-cv-killer/assets/sourcing/apps.py create mode 100644 apx-cv-killer/assets/sourcing/migrations/0001_initial.py create mode 100644 apx-cv-killer/assets/sourcing/migrations/__init__.py create mode 100644 apx-cv-killer/assets/sourcing/models.py create mode 100644 apx-cv-killer/assets/sourcing/requete_openclaw.py create mode 100644 apx-cv-killer/assets/sourcing/services.py create mode 100644 apx-cv-killer/assets/sourcing/urls.py create mode 100644 apx-cv-killer/assets/sourcing/views.py create mode 100644 apx-cv-killer/assets/templates/base.html create mode 100644 apx-cv-killer/assets/templates/sourcing/candidate_detail.html create mode 100644 apx-cv-killer/assets/templates/sourcing/index.html create mode 100644 apx-cv-killer/assets/templates/sourcing/results.html diff --git a/apx-cv-killer/.env.example b/apx-cv-killer/.env.example new file mode 100644 index 0000000..18181bb --- /dev/null +++ b/apx-cv-killer/.env.example @@ -0,0 +1,4 @@ +# 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 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..3fc9e49 --- /dev/null +++ b/apx-cv-killer/app.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../schemas/app.schema.json", + "name": "Your App Name", + "description": "A short description of what your app does (10-300 characters).", + "credentials": { + "source_keys": [], + "board_keys": [], + "algorithm_key": "" + }, + "settings": { + "team_name": "Your Team Name", + "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/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..3552305 --- /dev/null +++ b/apx-cv-killer/assets/cv_killer/settings.py @@ -0,0 +1,94 @@ +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' + +# External API Keys +OPENCLAW_API_KEY = os.getenv('OPENCLAW_API_KEY', '') +HRFLOW_API_KEY = os.getenv('HRFLOW_API_KEY', '') +HRFLOW_SOURCE_KEY = os.getenv('HRFLOW_SOURCE_KEY', '') + +# External API Endpoints +OPENCLAW_BASE_URL = os.getenv('OPENCLAW_BASE_URL', 'https://api.openclaw.io') +HRFLOW_BASE_URL = os.getenv('HRFLOW_BASE_URL', 'https://api.hrflow.ai/v1') + +# App Configuration +MAX_RESULTS = int(os.getenv('MAX_RESULTS', '10')) +FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB 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 0000000000000000000000000000000000000000..97d89ba818af9ba5db35d5c588d8d40b8843c724 GIT binary patch literal 163840 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 J2s{S@{}1x$s)zsp literal 0 HcmV?d00001 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/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/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 %} From ebc082b77a8b7fe68a769d5affcafdf304c10958 Mon Sep 17 00:00:00 2001 From: "aguediche@live.fr" Date: Sat, 28 Mar 2026 14:11:26 +0100 Subject: [PATCH 2/7] Ajout du script de scraping github --- apx-cv-killer/.env.example | 1 + .../assets/sourcing/github_scraping.py | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 apx-cv-killer/assets/sourcing/github_scraping.py diff --git a/apx-cv-killer/.env.example b/apx-cv-killer/.env.example index 18181bb..7baad74 100644 --- a/apx-cv-killer/.env.example +++ b/apx-cv-killer/.env.example @@ -2,3 +2,4 @@ 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/assets/sourcing/github_scraping.py b/apx-cv-killer/assets/sourcing/github_scraping.py new file mode 100644 index 0000000..d09a6f5 --- /dev/null +++ b/apx-cv-killer/assets/sourcing/github_scraping.py @@ -0,0 +1,175 @@ +import requests +from dotenv import load_dotenv +import os +# Charger les variables +load_dotenv() + +GITHUB_TOKEN = os.getenv("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 texte unique utilisable pour gestion de candidature. + + Contenu: + - informations principales (nom, login, url, bio, etc.) + - readme de profil utilisateur (si disponible, dans repo username/username) + - readme des 3 plus gros repos si `hireable`=true, sinon None. + """ + + profile = get_github_user_profile(username, token=token) + + summary_lines = [] + summary_lines.append(f"Nom: {profile.get('name') or 'N/A'}") + summary_lines.append(f"Login: {profile.get('login')}") + summary_lines.append(f"URL: {profile.get('html_url')}") + summary_lines.append(f"Bio: {profile.get('bio') or 'N/A'}") + summary_lines.append(f"Entreprise: {profile.get('company') or 'N/A'}") + summary_lines.append(f"Localisation: {profile.get('location') or 'N/A'}") + summary_lines.append(f"Site Web: {profile.get('blog') or 'N/A'}") + summary_lines.append(f"Email: {profile.get('email') or 'N/A'}") + summary_lines.append(f"Hireable: {profile.get('hireable')}") + summary_lines.append(f"Public repos: {profile.get('public_repos')}") + summary_lines.append(f"Followers: {profile.get('followers')}, Following: {profile.get('following')}") + summary_lines.append(f"Créé le: {profile.get('created_at')}") + + summary_lines.append("\n=== README UTILISATEUR ===") + profile_readme = _get_readme_from_repo(username, username, token=token) + if profile_readme: + summary_lines.append(profile_readme) + else: + summary_lines.append("Aucun README de profil trouvé (repo de profil non existant ou sans README)") + + if not profile.get("hireable"): + return None + + summary_lines.append("\n=== README DES 3 PLUS GROS PROJETS ===") + + 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] + + if not top_repos: + summary_lines.append("Aucun dépôt utilisateur trouvé") + else: + for repo in top_repos: + repo_name = repo.get("name") + summary_lines.append(f"-- {repo_name} ({repo.get('html_url')}) taille={repo.get('size')}") + readme = _get_readme_from_repo(username, repo_name, token=token) + if readme: + summary_lines.append(readme) + else: + summary_lines.append("Aucun README trouvé pour ce dépôt") + + return "\n".join(summary_lines) + + +if __name__ == "__main__": + + # Exemple d'usage : récupérer exactement 100 profils Python à Paris (si disponibles) + # mettre votre token ici (ghp_xxx) pour ne pas être bloqué + users = get_github_profiles_one_page("location:Paris language:Python", token=GITHUB_TOKEN) + print(f"Trouvé {len(users)} utilisateurs") + for u in users: + print(u.get("login"), u.get("html_url")) + + # Exemple d'usage : récupérer un profil utilisateur pour gestion de candidatures + if users: + for i in range(20): + candidate_login = users[i].get("login") + profile = get_github_user_profile(candidate_login, token=GITHUB_TOKEN) + print("Profil candidat:", profile) + + # Exemple d'usage avancé : résumé synthétique utilisable (texte) pour outils RH + summary = get_github_candidate_summary(candidate_login, token=GITHUB_TOKEN) + print("\n=== Résumé candidat ===") + print(summary) From 2d68f99bedffc158c10354966e1848d1fdb2375c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=A9tan=20D=C3=A9got-Silvestre?= Date: Sat, 28 Mar 2026 14:34:12 +0100 Subject: [PATCH 3/7] current status --- apx-cv-killer/assets/cv_killer/settings.py | 30 +++- .../assets/sourcing/hrflow_service.py | 162 ++++++++++++++++++ apx-cv-killer/assets/tests/test_hrflow.py | 110 ++++++++++++ 3 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 apx-cv-killer/assets/sourcing/hrflow_service.py create mode 100644 apx-cv-killer/assets/tests/test_hrflow.py diff --git a/apx-cv-killer/assets/cv_killer/settings.py b/apx-cv-killer/assets/cv_killer/settings.py index 3552305..26eb6e3 100644 --- a/apx-cv-killer/assets/cv_killer/settings.py +++ b/apx-cv-killer/assets/cv_killer/settings.py @@ -80,15 +80,33 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -# External API Keys -OPENCLAW_API_KEY = os.getenv('OPENCLAW_API_KEY', '') +# ========================================== +# 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', '') + +# 3. HrFlow.ai Configuration HRFLOW_API_KEY = os.getenv('HRFLOW_API_KEY', '') HRFLOW_SOURCE_KEY = os.getenv('HRFLOW_SOURCE_KEY', '') - -# External API Endpoints -OPENCLAW_BASE_URL = os.getenv('OPENCLAW_BASE_URL', 'https://api.openclaw.io') +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', '') -# App Configuration +# 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/sourcing/hrflow_service.py b/apx-cv-killer/assets/sourcing/hrflow_service.py new file mode 100644 index 0000000..561b43a --- /dev/null +++ b/apx-cv-killer/assets/sourcing/hrflow_service.py @@ -0,0 +1,162 @@ +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, title, description, skills=None, location_text="Remote"): + endpoint = f"{self.base_url}/job/indexing" + payload = { + "board_key": self.board_key, + "reference": str(reference), + "name": title, + "location": {"text": location_text, "lat": None, "lng": None}, + "sections": [{"name": "description", "title": "Job Description", "description": description}], + "skills": [{"name": skill, "value": None, "type": "hard"} for skill in (skills or [])] + } + + response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) + self._handle_error(response) + + logger.info(f"Successfully indexed Job: {reference}") + return response.json() + + def parse_profile(self, reference, file_path=None, raw_text=None, source_url=None): + """ + Hackathon Bypass: Since the free tier blocks synchronous AI Document Parsing, + we index the raw scraped text directly. HrFlow's semantic scoring engine + will still read the 'text' field to generate accurate match scores! + """ + # If no raw text is provided but a file is, try to read it as plain text (fallback) + if not raw_text and file_path: + try: + with open(file_path, 'r', encoding='utf-8') as f: + raw_text = f.read() + except: + raw_text = "See attached file." + + endpoint = f"{self.base_url}/profile/indexing" + + # We use dummy 'info' to instantly pass the strict database validation, + # but feed the entire OpenClaw scrape into the 'text' field for the Scoring AI. + payload = { + "source_key": self.source_key, + "profile": { + "reference": str(reference), + "text": raw_text or "Empty scrape", + "info": { + "first_name": "OpenClaw", # <-- THE FIX + "last_name": "Candidate", # <-- THE FIX + "full_name": "OpenClaw Candidate", + "email": "candidate@openclaw.local", + "location": {"text": "Remote", "lat": None, "lng": None}, + "urls": [{"type": "source", "url": source_url}] if source_url else [] + }, + "experiences": [], + "educations": [], + "skills": [] + } + } + + response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) + self._handle_error(response) + + logger.info(f"Successfully instantly indexed Profile: {reference}") + return 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}/profile/scoring" + params = { + 'algorithm_key': self.algorithm_key, + 'source_keys': json.dumps([self.source_key]), + 'board_key': self.board_key, + 'job_reference': str(job_reference), + 'use_agent': 1 + } + response = requests.get(endpoint, headers=self._get_headers(is_json=True), params=params) + self._handle_error(response) + return response.json().get('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() \ No newline at end of file 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..c8b6e8d --- /dev/null +++ b/apx-cv-killer/assets/tests/test_hrflow.py @@ -0,0 +1,110 @@ +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-007" + print("\n📝 TEST 1: Indexing User-Created Job...") + try: + service.index_job( + reference=job_ref, + title="Senior Django Developer", + description="Looking for an expert Python/Django developer for a hackathon project.", + skills=["Python", "Django", "APIs"], + location_text="Paris, France" + ) + print("✅ Job indexed successfully!") + except Exception as e: + print(f"❌ Job indexing failed: {e}") + + # --- TEST 2: Parse a Profile (OpenClaw Simulation) --- + profile_ref = "test-profile-http-007" + 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 15 seconds for AI Vector Indexing to process the raw text...") + time.sleep(15) + # --- 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 From 9c395169782ef845642962b19d6640d20c985fc5 Mon Sep 17 00:00:00 2001 From: "aguediche@live.fr" Date: Sat, 28 Mar 2026 15:21:44 +0100 Subject: [PATCH 4/7] ajustement du scraping github et du parsing --- .../assets/sourcing/github_scraping.py | 133 +++++++++++------- .../assets/sourcing/hrflow_service.py | 54 +++++-- 2 files changed, 124 insertions(+), 63 deletions(-) diff --git a/apx-cv-killer/assets/sourcing/github_scraping.py b/apx-cv-killer/assets/sourcing/github_scraping.py index d09a6f5..8878807 100644 --- a/apx-cv-killer/assets/sourcing/github_scraping.py +++ b/apx-cv-killer/assets/sourcing/github_scraping.py @@ -89,42 +89,38 @@ def get_github_user_profile(username, token=None): def get_github_candidate_summary(username, token=None): - """Retourne un texte unique utilisable pour gestion de candidature. + """Retourne un dictionnaire avec les informations du profil GitHub. Contenu: - informations principales (nom, login, url, bio, etc.) - - readme de profil utilisateur (si disponible, dans repo username/username) + - 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) - summary_lines = [] - summary_lines.append(f"Nom: {profile.get('name') or 'N/A'}") - summary_lines.append(f"Login: {profile.get('login')}") - summary_lines.append(f"URL: {profile.get('html_url')}") - summary_lines.append(f"Bio: {profile.get('bio') or 'N/A'}") - summary_lines.append(f"Entreprise: {profile.get('company') or 'N/A'}") - summary_lines.append(f"Localisation: {profile.get('location') or 'N/A'}") - summary_lines.append(f"Site Web: {profile.get('blog') or 'N/A'}") - summary_lines.append(f"Email: {profile.get('email') or 'N/A'}") - summary_lines.append(f"Hireable: {profile.get('hireable')}") - summary_lines.append(f"Public repos: {profile.get('public_repos')}") - summary_lines.append(f"Followers: {profile.get('followers')}, Following: {profile.get('following')}") - summary_lines.append(f"Créé le: {profile.get('created_at')}") + 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') - summary_lines.append("\n=== README UTILISATEUR ===") profile_readme = _get_readme_from_repo(username, username, token=token) - if profile_readme: - summary_lines.append(profile_readme) - else: - summary_lines.append("Aucun README de profil trouvé (repo de profil non existant ou sans README)") + profile_info['profile_readme'] = profile_readme or 'Aucun README de profil trouvé' if not profile.get("hireable"): return None - summary_lines.append("\n=== README DES 3 PLUS GROS PROJETS ===") - headers = {} if token: headers["Authorization"] = f"Bearer {token}" @@ -138,38 +134,79 @@ def get_github_candidate_summary(username, token=None): repos = repo_resp.json() or [] top_repos = repos[:3] - if not top_repos: + 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 top_repos: - repo_name = repo.get("name") - summary_lines.append(f"-- {repo_name} ({repo.get('html_url')}) taille={repo.get('size')}") - readme = _get_readme_from_repo(username, repo_name, token=token) - if readme: - summary_lines.append(readme) - else: - summary_lines.append("Aucun README trouvé pour ce dépôt") + for repo in profile_info['top_repos']: + summary_lines.append(f"-- {repo['name']} ({repo['url']}) taille={repo['size']}") + summary_lines.append(repo['readme']) - return "\n".join(summary_lines) + 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() - # Exemple d'usage : récupérer exactement 100 profils Python à Paris (si disponibles) - # mettre votre token ici (ghp_xxx) pour ne pas être bloqué + # 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 u in users: - print(u.get("login"), u.get("html_url")) - - # Exemple d'usage : récupérer un profil utilisateur pour gestion de candidatures - if users: - for i in range(20): - candidate_login = users[i].get("login") - profile = get_github_user_profile(candidate_login, token=GITHUB_TOKEN) - print("Profil candidat:", profile) - - # Exemple d'usage avancé : résumé synthétique utilisable (texte) pour outils RH - summary = get_github_candidate_summary(candidate_login, token=GITHUB_TOKEN) - print("\n=== Résumé candidat ===") - print(summary) + + 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 index 561b43a..3172533 100644 --- a/apx-cv-killer/assets/sourcing/hrflow_service.py +++ b/apx-cv-killer/assets/sourcing/hrflow_service.py @@ -75,35 +75,58 @@ def index_job(self, reference, title, description, skills=None, location_text="R logger.info(f"Successfully indexed Job: {reference}") return response.json() - def parse_profile(self, reference, file_path=None, raw_text=None, source_url=None): + def parse_profile(self, reference, profile_data=None, source_url=None, file_path=None): """ Hackathon Bypass: Since the free tier blocks synchronous AI Document Parsing, we index the raw scraped text directly. HrFlow's semantic scoring engine will still read the 'text' field to generate accurate match scores! """ - # If no raw text is provided but a file is, try to read it as plain text (fallback) - if not raw_text and file_path: + if not profile_data and file_path: try: with open(file_path, 'r', encoding='utf-8') as f: - raw_text = f.read() + profile_data = f.read() except: - raw_text = "See attached file." - + profile_data = "See attached file." + + if isinstance(profile_data, dict): + text = profile_data.get('text', 'Empty scrape') + name = profile_data.get('name', '') + first_name = name.split()[0] if name else '' + last_name = ' '.join(name.split()[1:]) if len(name.split()) > 1 else '' + full_name = name + email = profile_data.get('email', '') + location_text = profile_data.get('location', '') + source_url = source_url or profile_data.get('url') + else: + text = profile_data or "Empty scrape" + # Parse the string + lines = text.split('\n') + info = {} + for line in lines: + if ': ' in line: + k, v = line.split(': ', 1) + info[k.lower()] = v.strip() + name = info.get('nom', '') + first_name = name.split()[0] if name else '' + last_name = ' '.join(name.split()[1:]) if len(name.split()) > 1 else '' + full_name = name + email = info.get('email', '') + location_text = info.get('localisation', '') + source_url = source_url or info.get('url') + endpoint = f"{self.base_url}/profile/indexing" - # We use dummy 'info' to instantly pass the strict database validation, - # but feed the entire OpenClaw scrape into the 'text' field for the Scoring AI. payload = { "source_key": self.source_key, "profile": { "reference": str(reference), - "text": raw_text or "Empty scrape", + "text": text, "info": { - "first_name": "OpenClaw", # <-- THE FIX - "last_name": "Candidate", # <-- THE FIX - "full_name": "OpenClaw Candidate", - "email": "candidate@openclaw.local", - "location": {"text": "Remote", "lat": None, "lng": None}, + "first_name": first_name, + "last_name": last_name, + "full_name": full_name, + "email": email, + "location": {"text": location_text, "lat": None, "lng": None}, "urls": [{"type": "source", "url": source_url}] if source_url else [] }, "experiences": [], @@ -159,4 +182,5 @@ def send_rating(self, job_ref, profile_ref, is_shortlisted, comment=""): } response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) self._handle_error(response) - return response.json() \ No newline at end of file + return response.json() + From 97738b4c1b936a4714de870bf294fcdd829de7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=A9tan=20D=C3=A9got-Silvestre?= Date: Sat, 28 Mar 2026 15:45:16 +0100 Subject: [PATCH 5/7] fix parsing --- .../assets/sourcing/github_scraping.py | 2 +- .../assets/sourcing/hrflow_service.py | 91 +++++++++++++------ apx-cv-killer/assets/tests/test_hrflow.py | 12 +-- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/apx-cv-killer/assets/sourcing/github_scraping.py b/apx-cv-killer/assets/sourcing/github_scraping.py index d09a6f5..982e3c2 100644 --- a/apx-cv-killer/assets/sourcing/github_scraping.py +++ b/apx-cv-killer/assets/sourcing/github_scraping.py @@ -157,7 +157,7 @@ def get_github_candidate_summary(username, token=None): # Exemple d'usage : récupérer exactement 100 profils Python à Paris (si disponibles) # mettre votre token ici (ghp_xxx) pour ne pas être bloqué - users = get_github_profiles_one_page("location:Paris language:Python", token=GITHUB_TOKEN) + users = get_github_profiles_one_page("location:Paris language:Rust", token=GITHUB_TOKEN) print(f"Trouvé {len(users)} utilisateurs") for u in users: print(u.get("login"), u.get("html_url")) diff --git a/apx-cv-killer/assets/sourcing/hrflow_service.py b/apx-cv-killer/assets/sourcing/hrflow_service.py index 561b43a..d5676ec 100644 --- a/apx-cv-killer/assets/sourcing/hrflow_service.py +++ b/apx-cv-killer/assets/sourcing/hrflow_service.py @@ -78,45 +78,57 @@ def index_job(self, reference, title, description, skills=None, location_text="R def parse_profile(self, reference, file_path=None, raw_text=None, source_url=None): """ Hackathon Bypass: Since the free tier blocks synchronous AI Document Parsing, - we index the raw scraped text directly. HrFlow's semantic scoring engine - will still read the 'text' field to generate accurate match scores! + we use the Text Parsing API to extract structured data (skills, experiences), + and then feed that directly into the instant Profile Indexing API! """ - # If no raw text is provided but a file is, try to read it as plain text (fallback) if not raw_text and file_path: try: with open(file_path, 'r', encoding='utf-8') as f: raw_text = 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] + + # Safely extract the structured arrays generated by the AI + extracted_experiences = parsed_data.get("experiences", []) + extracted_educations = parsed_data.get("educations", []) + extracted_skills = parsed_data.get("skills", []) + extracted_info = parsed_data.get("info", {}) - endpoint = f"{self.base_url}/profile/indexing" + # --------------------------------------------------------- + # 2. PROFILE INDEXING (Saving it to the database) + # --------------------------------------------------------- + index_endpoint = f"{self.base_url}/profile/indexing" - # We use dummy 'info' to instantly pass the strict database validation, - # but feed the entire OpenClaw scrape into the 'text' field for the Scoring AI. - payload = { + index_payload = { "source_key": self.source_key, "profile": { "reference": str(reference), - "text": raw_text or "Empty scrape", - "info": { - "first_name": "OpenClaw", # <-- THE FIX - "last_name": "Candidate", # <-- THE FIX - "full_name": "OpenClaw Candidate", - "email": "candidate@openclaw.local", - "location": {"text": "Remote", "lat": None, "lng": None}, - "urls": [{"type": "source", "url": source_url}] if source_url else [] - }, - "experiences": [], - "educations": [], - "skills": [] + **parsed_data["profile"] } } - response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) - self._handle_error(response) + 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 instantly indexed Profile: {reference}") - return response.json() + 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.""" @@ -135,17 +147,42 @@ def get_profile(self, reference): return None def get_candidate_scores(self, job_reference): - endpoint = f"{self.base_url}/profile/scoring" + endpoint = f"{self.base_url}/profiles/scoring" params = { 'algorithm_key': self.algorithm_key, 'source_keys': json.dumps([self.source_key]), 'board_key': self.board_key, - 'job_reference': str(job_reference), - 'use_agent': 1 + '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) - return response.json().get('data', []) + + 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" diff --git a/apx-cv-killer/assets/tests/test_hrflow.py b/apx-cv-killer/assets/tests/test_hrflow.py index c8b6e8d..7d8c314 100644 --- a/apx-cv-killer/assets/tests/test_hrflow.py +++ b/apx-cv-killer/assets/tests/test_hrflow.py @@ -29,7 +29,7 @@ def run_tests(): return # --- TEST 1: Index a Job (User Input Simulation) --- - job_ref = "test-job-http-007" + job_ref = "test-job-http-010" print("\n📝 TEST 1: Indexing User-Created Job...") try: service.index_job( @@ -44,7 +44,7 @@ def run_tests(): print(f"❌ Job indexing failed: {e}") # --- TEST 2: Parse a Profile (OpenClaw Simulation) --- - profile_ref = "test-profile-http-007" + profile_ref = "test-profile-http-010" print("\n👤 TEST 2: AI Parsing OpenClaw Scraped Profile...") try: raw_cv_text = """ @@ -78,8 +78,8 @@ def run_tests(): print("\nℹ️ Skipping PDF test (No 'test_cv.pdf' found in root).") - print("\n⏳ Waiting 15 seconds for AI Vector Indexing to process the raw text...") - time.sleep(15) + 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: @@ -92,7 +92,7 @@ def run_tests(): print(f"❌ Scoring failed: {e}") # --- TEST 5: Rating Signal --- - print("\n👎 TEST 5: Sending Rejection Signal...") + """print("\n👎 TEST 5: Sending Rejection Signal...") try: service.send_rating( job_ref=job_ref, @@ -102,7 +102,7 @@ def run_tests(): ) print("✅ Rating signal sent successfully!") except Exception as e: - print(f"❌ Rating failed: {e}") + print(f"❌ Rating failed: {e}")""" print("\n🎉 Integration testing complete.") From 80185cbbf3493a9c0c75ae5339efc869593e2d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=A9tan=20D=C3=A9got-Silvestre?= Date: Sat, 28 Mar 2026 16:55:45 +0100 Subject: [PATCH 6/7] improved job indexing --- apx-cv-killer/assets/create_hrflow_job.py | 63 +++++++++++++++++++ .../assets/sourcing/github_scraping.py | 2 +- .../assets/sourcing/hrflow_service.py | 62 ++++++++++++------ apx-cv-killer/assets/tests/test_hrflow.py | 23 +++---- 4 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 apx-cv-killer/assets/create_hrflow_job.py 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/sourcing/github_scraping.py b/apx-cv-killer/assets/sourcing/github_scraping.py index 8878807..f2232ba 100644 --- a/apx-cv-killer/assets/sourcing/github_scraping.py +++ b/apx-cv-killer/assets/sourcing/github_scraping.py @@ -4,7 +4,7 @@ # Charger les variables load_dotenv() -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +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. diff --git a/apx-cv-killer/assets/sourcing/hrflow_service.py b/apx-cv-killer/assets/sourcing/hrflow_service.py index edb0cfc..4b3daa0 100644 --- a/apx-cv-killer/assets/sourcing/hrflow_service.py +++ b/apx-cv-killer/assets/sourcing/hrflow_service.py @@ -58,24 +58,54 @@ def _handle_error(self, response): # PUBLIC API # ------------------------------------------------------------------------- - def index_job(self, reference, title, description, skills=None, location_text="Remote"): - endpoint = f"{self.base_url}/job/indexing" - payload = { + 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, - "reference": str(reference), - "name": title, - "location": {"text": location_text, "lat": None, "lng": None}, - "sections": [{"name": "description", "title": "Job Description", "description": description}], - "skills": [{"name": skill, "value": None, "type": "hard"} for skill in (skills or [])] + "job": { + **job_parsed + } } - response = requests.post(endpoint, headers=self._get_headers(is_json=True), json=payload) - self._handle_error(response) + 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 indexed Job: {reference}") - return response.json() + logger.info(f"Successfully parsed and indexed Job: {reference}") + return index_response.json() - def parse_profile(self, reference, profile_data=None, source_url=None, file_path=None): + 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), @@ -104,12 +134,6 @@ def parse_profile(self, reference, profile_data=None, source_url=None, file_path # The AI returns a list of parsed objects (one for each text we sent) parsed_data = parse_response.json().get("data", [{}])[0] - - # Safely extract the structured arrays generated by the AI - extracted_experiences = parsed_data.get("experiences", []) - extracted_educations = parsed_data.get("educations", []) - extracted_skills = parsed_data.get("skills", []) - extracted_info = parsed_data.get("info", {}) # --------------------------------------------------------- # 2. PROFILE INDEXING (Saving it to the database) diff --git a/apx-cv-killer/assets/tests/test_hrflow.py b/apx-cv-killer/assets/tests/test_hrflow.py index 7d8c314..6b77773 100644 --- a/apx-cv-killer/assets/tests/test_hrflow.py +++ b/apx-cv-killer/assets/tests/test_hrflow.py @@ -29,22 +29,23 @@ def run_tests(): return # --- TEST 1: Index a Job (User Input Simulation) --- - job_ref = "test-job-http-010" - print("\n📝 TEST 1: Indexing User-Created Job...") + job_ref = "test-job-http-012" + print("\n📝 TEST 1: AI Parsing and Indexing Job...") try: - service.index_job( - reference=job_ref, - title="Senior Django Developer", - description="Looking for an expert Python/Django developer for a hackathon project.", - skills=["Python", "Django", "APIs"], - location_text="Paris, France" - ) - print("✅ Job indexed successfully!") + 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-010" + profile_ref = "test-profile-http-012" print("\n👤 TEST 2: AI Parsing OpenClaw Scraped Profile...") try: raw_cv_text = """ From 9bdfdfaa1189f0f99bd2e4c537330c53aa7f91f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=A9tan=20D=C3=A9got-Silvestre?= Date: Sat, 28 Mar 2026 18:03:22 +0100 Subject: [PATCH 7/7] feat: add cv-killer --- apx-cv-killer/app.json | 12 ++++++------ apx-cv-killer/assets/cv_killer/settings.py | 2 ++ apx-cv-killer/assets/db.sqlite3 | Bin 163840 -> 163840 bytes apx-cv-killer/assets/preview.jpg | Bin 0 -> 30921 bytes .../assets/sourcing/hrflow_service.py | 6 +++--- 5 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 apx-cv-killer/assets/preview.jpg diff --git a/apx-cv-killer/app.json b/apx-cv-killer/app.json index 3fc9e49..6b3aca2 100644 --- a/apx-cv-killer/app.json +++ b/apx-cv-killer/app.json @@ -1,14 +1,14 @@ { "$schema": "../../schemas/app.schema.json", - "name": "Your App Name", - "description": "A short description of what your app does (10-300 characters).", + "name": "CV Killer", + "description": "AI recruitment tool", "credentials": { - "source_keys": [], - "board_keys": [], - "algorithm_key": "" + "source_keys": ["source_key"], + "board_keys": ["board_key"], + "algorithm_key": "algo_key" }, "settings": { - "team_name": "Your Team Name", + "team_name": "APX", "theme_color": "#5B5BE1", "custom_filters": [], "filters": [] diff --git a/apx-cv-killer/assets/cv_killer/settings.py b/apx-cv-killer/assets/cv_killer/settings.py index 26eb6e3..e09ca45 100644 --- a/apx-cv-killer/assets/cv_killer/settings.py +++ b/apx-cv-killer/assets/cv_killer/settings.py @@ -92,6 +92,8 @@ # 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', '') diff --git a/apx-cv-killer/assets/db.sqlite3 b/apx-cv-killer/assets/db.sqlite3 index 97d89ba818af9ba5db35d5c588d8d40b8843c724..434df179454168e032b50be64b22916dc3e69e69 100644 GIT binary patch delta 1229 zcmb`GJ8aWX6o#+u*lj}Mn<@%wXr&qnl&OBdRjC9r6bbPXRH#CN#CAxcG>+oLg2@T6 zArMTJ5EBy{MI9={%ESZ`b0!v;Sm?xnoj4&eDispT8P0$G`@8r2r`JpLdWok?i2iK( z9HPIu{c>e758^Nl;8*w=zJo4&1fRnjFb&uCPErRXmrY^};@I7`QET40RkzD_rDC-S z_zGm>leS}(+D6qmk-{`&hcb}k1hIS!Tc6+gR7P?STxQ>8kS8D01RMbP6CS|t@EiOB z2YU;x4^YN21R;o^;&@PFv7lmDP*D`a;_(y!fFL&k+=X>`iX4zH$W5Q_Ea?@AIgrbw z3I)?O+;ZJ;n>@?QtE{lfs}v{e9ILYuE%Cf4h=(g@@HvoK@Lf8VV^(I*v8v80v>~x1>#;PCZWkNN)M>E+xI!AeWmj6#AEt zN~zYcOtZg8B*I>P+#8N$)_*(?Srj6F4i!li1?7CxEj4N;b^YoED$k6&M@JJ>qI|``)rwjcg*GwdKN4k zvm%OeBq*Vxs z4wixk14zqin*Qd`a(Vu0g@MxqPL?NNBd$>thYGfV{@p5Y8ih_|S$|Py)^}P#?-!9U MVh{J<)9BijUu`TgHUIzs delta 178 zcmV;j08RgZfC_+s3XmHCCy^XO0VlCwq8}Kj00|E#01xR8)DOE4b`OgWnh#hHLJudi z5nv4u1sM$kISG?NFBTgV3HP85sycL4+v@W?^Y;Wn^Txxh?`nQRIv;hX4Qo diff --git a/apx-cv-killer/assets/preview.jpg b/apx-cv-killer/assets/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a85c9be053c64c9230f26ec210f85d9432fb85f GIT binary patch literal 30921 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