This commit is contained in:
el 2025-06-24 18:17:53 +02:00
commit d7666f7b2c
44 changed files with 2246 additions and 0 deletions

69
.gitignore vendored Normal file
View file

@ -0,0 +1,69 @@
# Byte-code files
*.pyc
__pycache__/
.Python/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.manifest
*.whl
# Environment variables
.env
.venv/
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE-specific files
.idea/
.vscode/
*.swp
*.bak
*.swo
# OS generated files
.DS_Store
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
*.sqlite3 # Au cas où une base de données SQLite serait utilisée temporairement
# FastAPI / Uvicorn / Project specific
# Docker-related
*.pem # Clés SSL/TLS
*.key # Clés privées
*.crt # Certificats
*.csr # Demandes de signature de certificat
*.jks # Java KeyStore
# Generated by FastAPI / Uvicorn / other tools
# Cache directory for google.genai or other libraries
.cache/
.pytest_cache/
# User uploaded files (as defined in config.py UPLOADS_DIR)
# Assurez-vous que ce chemin correspond à votre settings.UPLOADS_DIR
# Si votre dossier d'uploads est ailleurs ou est nommé différemment, ajustez ce chemin.
uploads/
# Docker build context
.dockerignore
# Misc
.coverage
.mypy_cache/

80
README.md Normal file
View file

@ -0,0 +1,80 @@
# Allons-y - Assistant de Candidature IA
## Introduction
Bienvenue dans le projet **Allons-y - Assistant de Candidature IA** !
Cette application web a pour objectif de simplifier et d'accélérer le processus de candidature à un emploi en tirant parti de l'intelligence artificielle. Elle offre aux candidats des outils puissants pour optimiser leurs CV, lettres de motivation, et adapter leurs candidatures aux offres d'emploi.
Actuellement, nous nous concentrons sur le développement du **Backend (API)** pour établir les fondations solides de l'application, y compris l'authentification, la gestion des documents et l'intégration des fonctionnalités d'IA.
Que vous soyez un jeune diplômé ou un professionnel expérimenté en quête de nouvelles opportunités, Allons-y est conçu pour vous aider à maximiser vos chances de succès.
## Fonctionnalités Principales (MVP et Futures)
* **Analyse d'Offre (Scoring) :** Évalue la pertinence d'un CV par rapport à une offre d'emploi.
* **Génération de Paragraphe de Motivation :** Crée des paragraphes personnalisés pour les lettres de motivation.
* **Reformulation de Compétences :** Améliore la formulation des compétences sur un CV.
* **Questions d'Entretien :** Génère des questions potentielles basées sur une offre ou un secteur.
* **Mots-clés CV :** Suggère des mots-clés pertinents à inclure dans le CV.
* **Gestion de Compte Utilisateur :** Inscription, connexion, gestion de profil, historique des interactions.
* **Gestion des Documents :** Téléchargement, stockage et gestion sécurisée des CVs et lettres de motivation.
* **Accès Anonyme Limité :** Possibilité d'essayer certaines fonctionnalités sans inscription pour découvrir l'application.
## Technologies Utilisées
* **Frontend :** React.js (développement futur)
* **Backend :** Python (avec FastAPI)
* **Base de Données :** PostgreSQL
* **Intelligence Artificielle :** APIs Gemini (Google) et/ou Mistral
* **Stockage de Fichiers :** (À définir : Local pour POC, S3/GCS pour production)
## Structure du Projet
Le projet est organisé en plusieurs répertoires principaux :
* `backend/` : Contient le code source de l'API Python. **C'est le focus actuel du développement.**
* `frontend/` : Sera développé ultérieurement pour l'interface utilisateur.
* `documentation/` : Contient des documents additionnels, schémas, etc.
* `README.md` : Ce fichier.
* `ROADMAP.md` : Feuille de route du projet.
* `PRD.md` : Product Requirements Document.
## Comment Démarrer le Projet (Développement Local)
Des instructions détaillées pour lancer le backend se trouvent dans son `README.md` respectif.
### Pré-requis Généraux :
* Python 3.9+ et pip pour le backend.
* Docker et Docker Compose (recommandé pour la base de données et un environnement cohérent).
### Étapes Générales :
1. **Cloner le dépôt :**
```bash
git clone [https://git-scm.com/book/fr/v2/Les-bases-de-Git-D%C3%A9marrer-un-d%C3%A9p%C3%B4t-Git](https://git-scm.com/book/fr/v2/Les-bases-de-Git-D%C3%A9marrer-un-d%C3%A9p%C3%B4t-Git)
cd allons_y
```
2. **Configuration des variables d'environnement :**
* Copiez le fichier `.env.example` en `.env` dans le répertoire `backend/`.
* Renseignez les valeurs nécessaires (clés API, identifiants DB, etc.).
3. **Lancer la base de données (PostgreSQL) :**
* (Si Docker est utilisé) Créez un `docker-compose.yml` à la racine pour la base de données, puis utilisez `docker-compose up -d postgres`.
4. **Lancer le Backend :**
```bash
cd backend
pip install -r requirements.txt
uvicorn main:app --reload # ou commande FastAPI équivalente
```
Consultez `backend/README.md` pour plus de détails.
## Contributions
Les contributions sont les bienvenues ! Veuillez consulter les `ROADMAP.md` et `PRD.md` pour comprendre la vision et les exigences du projet.
## Licence
Ce projet est sous licence MIT.
---

6
backend/.env.example Normal file
View file

@ -0,0 +1,6 @@
DATABASE_URL="postgresql://user:password@host:port/allonsy_db" # Remplacez 'host', 'port', 'user', 'password' par les infos de votre DB distante
SECRET_KEY="your_super_secret_key_for_jwt_and_hashing" # Générez une clé forte et longue
ALGORITHM="HS256"
GEMINI_API_KEY="your_gemini_api_key_here" # Clé API Google Gemini
MISTRAL_API_KEY="your_mistral_api_key_here" # Clé API Mistral AI
FILES_UPLOAD_PATH="./uploads" # Chemin local pour le stockage des fichiers uploadés

71
backend/README.md Normal file
View file

@ -0,0 +1,71 @@
# Backend - Allons-y API
Ce dossier contient le code source de l'API RESTful pour l'application "Allons-y - Assistant de Candidature IA". Il est développé en Python avec le framework FastAPI.
## Technologies
* **Framework :** FastAPI
* **Base de Données ORM :** SQLAlchemy
* **Base de Données :** PostgreSQL (connexion à une base de données distante)
* **Authentification :** JWT avec `python-jose` et hachage de mot de passe avec `passlib[bcrypt]`
* **Analyse de Fichiers :** `pypdf` pour les PDF, `python-docx` pour les DOCX
* **IA APIs :** `google-generativeai` pour Gemini, `mistralai` pour Mistral
* **Gestion des variables d'environnement :** `python-dotenv`
## Comment Démarrer le Backend (Développement Local)
### Pré-requis :
* Python 3.9+
* pip (gestionnaire de paquets Python)
* Accès à votre base de données PostgreSQL distante.
### Étapes :
1. **Naviguer dans le répertoire du backend :**
```bash
cd backend
```
2. **Créer et activer un environnement virtuel (recommandé) :**
```bash
python -m venv venv
source venv/bin/activate # Sur Linux/macOS
# venv\Scripts\activate # Sur Windows
```
3. **Installer les dépendances :**
```bash
pip install -r requirements.txt
```
4. **Configurer les variables d'environnement :**
* Copiez `.env.example` en `.env` :
```bash
cp .env.example .env
```
* **Éditez le fichier `.env`** et renseignez les valeurs exactes pour `DATABASE_URL` (avec le `host`, `port`, `user`, `password` de votre base de données distante), `SECRET_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`, et `FILES_UPLOAD_PATH`.
* **Important :** La `SECRET_KEY` doit être une chaîne de caractères longue et aléatoire pour la sécurité de vos JWT.
* Pour `FILES_UPLOAD_PATH`, assurez-vous que le répertoire `./uploads` existe ou sera créé et qu'il a les permissions d'écriture.
5. **Lancer l'application FastAPI :**
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
L'API sera accessible à `http://localhost:8000`. La documentation Swagger UI est disponible à `http://localhost:8000/docs`.
## Structure du Code (à venir)
* `main.py` : Point d'entrée de l'application FastAPI.
* `routers/` : Contient les routes de l'API (authentification, fichiers, IA).
* `models/` : Contient les modèles de base de données SQLAlchemy.
* `schemas/` : Contient les Pydantic schemas pour la validation des données.
* `crud/` : Contient les opérations CRUD pour la base de données.
* `utils/` : Fonctions utilitaires (sécurité, extraction de texte, etc.).
* `core/` : Configuration, dépendances.
## Endpoints Principaux (MVP)
* `POST /auth/register` : Inscription d'un nouvel utilisateur.
* `POST /auth/login` : Connexion de l'utilisateur.
* `GET /users/me` : Récupérer le profil utilisateur (authentifié).
* `POST /files/upload_cv` : Upload d'un CV.
* `POST /ia/analyze_offer` : Analyse d'offre d'emploi (scoring).
---

0
backend/__init__.py Normal file
View file

143
backend/alembic.ini Normal file
View file

@ -0,0 +1,143 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# alembic.ini (partie à modifier)
# [...]
# Mettez votre chaîne de connexion de base de données ici.
# par exemple, 'postgresql://user:password@host:port/dbname'
sqlalchemy.url = ${DATABASE_URL}
# [...]
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

90
backend/alembic/env.py Normal file
View file

@ -0,0 +1,90 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Ceci ajoute le répertoire 'backend' (où se trouve 'alembic.ini' et 'main.py')
# au chemin de recherche Python, permettant d'importer vos modules.
sys.path.append(os.path.abspath("."))
# Importez votre objet Base de core.database
from core.database import Base
# Importez tous vos modèles SQLAlchemy ici pour qu'Alembic puisse les détecter.
from models import user
from models import document
# this is the Alembic Config object, which provides
# access to values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import Base
# target_metadata = Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired a number of ways.
# in this example, we want to override the sqlalchemy.url from the ini file
# if a DATABASE_URL environment variable is present.
# Note: config.get_main_option() reads from alembic.ini, which we updated.
url = os.environ.get("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
if url:
config.set_main_option("sqlalchemy.url", url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an actual DBAPI connection. By doing this,
migrations can be run without a database present.
Methods can be called instead to produce a string
of content to be executed later,
e.g. env.py's Alembic.configure with a SQLAlchemy connection string.
"""
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create a connection
to the database before configuring Alembic.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,32 @@
"""Initial database setup with users and documents tables
Revision ID: 1eb03e5de010
Revises:
Create Date: 2025-06-20 23:49:35.265344
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1eb03e5de010'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

0
backend/core/__init__.py Normal file
View file

35
backend/core/config.py Normal file
View file

@ -0,0 +1,35 @@
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
# Chemin absolu vers le répertoire des uploads
# Par défaut, un dossier 'uploads' dans le répertoire 'backend'
UPLOADS_DIR: str = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "uploads")
# Secret key pour les JWT (à générer une valeur forte en production)
SECRET_KEY: str = os.getenv("SECRET_KEY") # Assurez-vous que c'est le même que celui utilisé dans security.py si vous l'avez hardcodé là-bas
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
MISTRAL_API_KEY: Optional[str] = None
GEMINI_API_KEY: Optional[str] = None
LLM_PROVIDER: str = "gemini" # Votre choix par défaut
# --- AJOUTEZ CES DEUX LIGNES ---
GEMINI_MODEL_NAME: Optional[str] = "gemini-1.5-flash" # Ou le nom de modèle Gemini que vous utilisez
MISTRAL_MODEL_NAME: Optional[str] = "mistral-tiny" # Ou le nom de modèle Mistral par défaut si vous l'utilisez
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# --- Nouvelles variables pour l'API France Travail ---
FRANCE_TRAVAIL_CLIENT_ID: str
FRANCE_TRAVAIL_CLIENT_SECRET: str
FRANCE_TRAVAIL_TOKEN_URL: str = "https://francetravail.io/connexion/oauth2/access_token?realm=%2Fpartenaire"
FRANCE_TRAVAIL_API_BASE_URL: str = "https://api.francetravail.io/partenaire/offresdemploi"
FRANCE_TRAVAIL_API_SCOPE: str = "o2dsoffre api_offresdemploiv2" # Les scopes requis par l'API
settings = Settings()
print(f"DEBUG: FRANCE_TRAVAIL_CLIENT_ID chargé: {settings.FRANCE_TRAVAIL_CLIENT_ID}")
print(f"DEBUG: FRANCE_TRAVAIL_CLIENT_SECRET chargé: {settings.FRANCE_TRAVAIL_CLIENT_SECRET}")
# Créer le dossier d'uploads s'il n'existe pas
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)

34
backend/core/database.py Normal file
View file

@ -0,0 +1,34 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
# Charger les variables d'environnement depuis le fichier .env
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL non défini dans les variables d'environnement.")
# Configuration de l'engine de la base de données
# Le paramètre connect_args={"check_same_thread": False} est nécessaire pour SQLite,
# mais peut être omis pour PostgreSQL en production. Gardons-le pour la flexibilité initiale.
engine = create_engine(
DATABASE_URL
)
# Configuration de la session de la base de données
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base de déclaration pour les modèles SQLAlchemy
Base = declarative_base()
# Fonction d'utilité pour obtenir une session de base de données (dépendance FastAPI)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

11
backend/core/hashing.py Normal file
View file

@ -0,0 +1,11 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Vérifie si un mot de passe clair correspond à un mot de passe haché."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hache un mot de passe clair."""
return pwd_context.hash(password)

55
backend/core/security.py Normal file
View file

@ -0,0 +1,55 @@
# backend/core/security.py
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
# Importations pour get_current_user
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from schemas.token import TokenData
from crud import user as crud_user
from core.database import get_db
# Importation ABSOLUE
from core.config import settings
# Nouvelle importation pour les fonctions de hachage
from core.hashing import verify_password, get_password_hash # <-- NOUVEAU
# Schéma OAuth2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
# Fonction get_current_user
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(email=username)
except JWTError:
raise credentials_exception
user = crud_user.get_user_by_email(db, email=token_data.email)
if user is None:
raise credentials_exception
return user
# LIGNE DE DÉBOGAGE CORRECTEMENT INDENTÉE (au niveau du module)
print(f"DEBUG_SECURITY: Noms définis dans core.security.py : {dir()}")

0
backend/crud/__init__py Normal file
View file

View file

@ -0,0 +1,29 @@
from sqlalchemy.orm import Session
from models import ai_interaction as models_ai_interaction
from schemas import ai_interaction as schemas_ai_interaction
def create_ai_interaction(db: Session, ai_interaction: schemas_ai_interaction.AiInteractionCreate):
"""Crée une nouvelle interaction IA dans la base de données."""
db_ai_interaction = models_ai_interaction.AiInteraction(
user_id=ai_interaction.user_id,
document_id=ai_interaction.document_id,
job_offer_text=ai_interaction.job_offer_text,
cv_text_used=ai_interaction.cv_text_used,
ai_request=ai_interaction.ai_request,
ai_response=ai_interaction.ai_response,
score=ai_interaction.score,
analysis_results=ai_interaction.analysis_results,
interaction_type=ai_interaction.interaction_type
)
db.add(db_ai_interaction)
db.commit()
db.refresh(db_ai_interaction)
return db_ai_interaction
def get_ai_interactions_by_user(db: Session, user_id: int):
"""Récupère toutes les interactions IA d'un utilisateur."""
return db.query(models_ai_interaction.AiInteraction).filter(models_ai_interaction.AiInteraction.user_id == user_id).all()
def get_ai_interaction_by_id(db: Session, interaction_id: int):
"""Récupère une interaction IA par son ID."""
return db.query(models_ai_interaction.AiInteraction).filter(models_ai_interaction.AiInteraction.id == interaction_id).first()

38
backend/crud/document.py Normal file
View file

@ -0,0 +1,38 @@
# backend/crud/document.py
from sqlalchemy.orm import Session
# Importations ABSOLUES
from models import document as models_document
from schemas import document as schemas_document
from typing import Optional
def create_document(db: Session, document: schemas_document.DocumentCreate, filepath: str, owner_id: int):
db_document = models_document.Document(
filename=document.filename,
filepath=filepath,
owner_id=owner_id
)
db.add(db_document)
db.commit()
db.refresh(db_document)
return db_document
def get_documents_by_owner(db: Session, owner_id: int):
return db.query(models_document.Document).filter(models_document.Document.owner_id == owner_id).all()
# Ceci est la définition correcte et finale de get_document_by_id
def get_document_by_id(db: Session, document_id: int, owner_id: int) -> Optional[models_document.Document]:
"""
Récupère un document par son ID et l'ID de son propriétaire.
Cela garantit qu'un utilisateur ne peut accéder qu'à ses propres documents.
"""
return db.query(models_document.Document).filter(
models_document.Document.id == document_id,
models_document.Document.owner_id == owner_id
).first()
def delete_document(db: Session, document_id: int):
db_document = db.query(models_document.Document).filter(models_document.Document.id == document_id).first()
if db_document:
db.delete(db_document)
db.commit()
return db_document

20
backend/crud/user.py Normal file
View file

@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
# Importations ABSOLUES
from models import user as models_user
from schemas import user as schemas_user
from core.hashing import get_password_hash # <-- NOUVEAU
def get_user_by_email(db: Session, email: str):
return db.query(models_user.User).filter(models_user.User.email == email).first()
def create_user(db: Session, user: schemas_user.UserCreate):
hashed_password = get_password_hash(user.password)
db_user = models_user.User(
email=user.email,
hashed_password=hashed_password,
name=user.name
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user

35
backend/dependencies.py Normal file
View file

@ -0,0 +1,35 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from core.config import settings
from core.database import get_db
from crud import user as crud_user
from schemas import user as schemas_user # Pour la validation du modèle de l'utilisateur
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") # URL où le client peut obtenir un token
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Impossible de valider les identifiants",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = crud_user.get_user_by_email(db, email=username)
if user is None:
raise credentials_exception
# Retourne l'utilisateur sous forme de Pydantic model pour la réponse
return schemas_user.UserResponse.model_validate(user)

55
backend/main.py Normal file
View file

@ -0,0 +1,55 @@
# backend/main.py
import sys
import os
# Ajoutez le répertoire parent du fichier actuel (qui est 'backend/') au PYTHONPATH
# Cela permet d'importer des modules depuis 'backend.services', 'backend.routers', etc.
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
# AJOUTEZ CETTE LIGNE TEMPORAIREMENT POUR LE DEBUG
print(f"DEBUG: sys.path = {sys.path}")
# FIN DE L'AJOUT TEMPORAIRE
# Le reste de vos imports
from fastapi import FastAPI
from contextlib import asynccontextmanager
from core.database import Base, engine
from models import user
from models import document
from models import ai_interaction
from routers import auth
from routers import document as document_router
from routers import ai as ai_router
from routers import france_travail_offers
# Cette fonction sera appelée au démarrage et à l'arrêt de l'application
@asynccontextmanager
async def lifespan(app: FastAPI):
print("L'application démarre. Les migrations de DB sont gérées par Alembic.")
yield
print("L'application s'arrête.")
app = FastAPI(
title="Allons-y API",
description="API pour l'assistant de candidature basé sur l'IA.",
version="0.1.0",
lifespan=lifespan,
openapi_tags=[
{"name": "Authentication", "description": "Opérations liées à l'authentification des utilisateurs."},
{"name": "Documents", "description": "Gestion des CVs et autres documents de l'utilisateur."},
{"name": "Offers (France Travail)", "description": "Recherche et consultation des offres d'emploi via l'API France Travail."}, # <-- NOUVELLE SECTION TAG
{"name": "AI Analysis", "description": "Endpoints pour l'analyse IA de CVs et offres d'emploi."},
]
)
# Inclure les routeurs
app.include_router(auth.router)
app.include_router(document_router.router)
app.include_router(ai_router.router)
app.include_router(france_travail_offers.router, prefix="/france-travail/offers", tags=["Offers (France Travail)"])
@app.get("/")
async def read_root():
return {"message": "Bienvenue sur l'API Allons-y Alonzo!"}

View file

View file

@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
class AiInteraction(Base):
__tablename__ = "ai_interactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Utilisateur ayant effectué l'interaction (peut être NULL pour anonyme)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=True) # Document utilisé pour l'interaction (si pertinent)
job_offer_text = Column(Text, nullable=False) # Le texte de l'offre d'emploi analysée
cv_text_used = Column(Text, nullable=True) # Le texte du CV utilisé pour l'analyse (stocké pour l'historique)
ai_request = Column(Text, nullable=False) # Le prompt envoyé à l'IA
ai_response = Column(Text, nullable=False) # La réponse brute de l'IA
score = Column(Float, nullable=True) # Le score de pertinence calculé par l'IA ou le backend
analysis_results = Column(Text, nullable=True) # Les détails de l'analyse (ex: points forts/faibles)
interaction_type = Column(String, nullable=False, default="scoring") # Type d'interaction (e.g., 'scoring', 'paragraph_gen')
created_at = Column(DateTime, default=func.now())
# Relations optionnelles
user = relationship("User", back_populates="ai_interactions")
document = relationship("Document") # Pas de back_populates ici car Document n'a pas de relation "ai_interactions"
def __repr__(self):
return f"<AiInteraction(id={self.id}, user_id={self.user_id}, type='{self.interaction_type}')>"

View file

@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String, nullable=False)
filepath = Column(String, unique=True, nullable=False) # Chemin unique pour le stockage
owner_id = Column(Integer, ForeignKey("users.id")) # Clé étrangère vers l'utilisateur
uploaded_at = Column(DateTime, default=func.now())
# Relation avec l'utilisateur propriétaire
owner = relationship("User", back_populates="documents")
def __repr__(self):
return f"<Document(filename='{self.filename}', owner_id={self.owner_id})>"

22
backend/models/user.py Normal file
View file

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship # <-- NOUVELLE IMPORTATION
from core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# Relation avec les documents de l'utilisateur
documents = relationship("Document", back_populates="owner") # <-- NOUVELLE LIGNE
ai_interactions = relationship("AiInteraction", back_populates="user") # <-- NOUVELLE LIGNE
def __repr__(self):
return f"<User(email='{self.email}', id={self.id})>"

View file

@ -0,0 +1,30 @@
# backend/repositories/document_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from models.document import Document
from typing import Optional, List
class DocumentRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_document_by_id(self, document_id: int, owner_id: int) -> Optional[Document]:
"""
Récupère un document par son ID et l'ID de son propriétaire.
Cela garantit qu'un utilisateur ne peut accéder qu'à ses propres documents.
"""
result = await self.db.execute(
select(Document).where(Document.id == document_id, Document.owner_id == owner_id)
)
return result.scalars().first()
async def get_all_documents_by_owner_id(self, owner_id: int) -> List[Document]:
"""
Récupère tous les documents pour un propriétaire donné.
"""
result = await self.db.execute(
select(Document).where(Document.owner_id == owner_id)
)
return result.scalars().all()
# Vous pourriez ajouter ici d'autres méthodes comme create_document, delete_document, etc.

12
backend/requirements.txt Normal file
View file

@ -0,0 +1,12 @@
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
python-jose[cryptography]
passlib[bcrypt]
python-dotenv
aiofiles
pypdf
python-docx
google-generativeai
mistralai

View file

213
backend/routers/ai.py Normal file
View file

@ -0,0 +1,213 @@
# backend/routers/ai.py (Mise à jour avec extraction de texte)
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, model_validator
from services.ai_service import ai_service
from core.security import get_current_user
from models.user import User
from typing import Optional
# NOUVELLE IMPORTATION pour le service France Travail
from services.france_travail_offer_service import france_travail_offer_service
# NOUVELLES IMPORTATIONS pour les documents et la base de données
from crud import document as crud_document
from models.document import Document
from core.database import get_db
from sqlalchemy.orm import Session
# NOUVELLES IMPORTATIONS pour l'extraction de texte
import os
import pypdf # Pour les fichiers PDF
import docx # Pour les fichiers DOCX (pip install python-docx)
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# Modèle de requête pour l'analyse d'offre
class AnalyzeRequest(BaseModel):
cv_id: Optional[int] = Field(None, description="ID du CV de l'utilisateur déjà stocké. Si fourni, cv_text sera ignoré.")
cv_text: Optional[str] = Field(None, description="Texte brut du CV à analyser. Utilisé si cv_id n'est pas fourni (ex: pour analyse anonyme).")
job_offer_text: Optional[str] = Field(None, description="Le texte complet de l'offre d'emploi à analyser (si pas d'offer_id).")
france_travail_offer_id: Optional[str] = Field(None, description="L'ID de l'offre France Travail à analyser (si pas de job_offer_text).")
@model_validator(mode='after')
def check_inputs_provided(self) -> 'AnalyzeRequest':
if not (self.cv_id or self.cv_text):
raise ValueError("Veuillez fournir un 'cv_id' ou un 'cv_text'.")
if not (self.job_offer_text or self.france_travail_offer_id):
raise ValueError("Au moins 'job_offer_text' ou 'france_travail_offer_id' doit être fourni pour l'offre d'emploi.")
return self
# Fonction utilitaire pour extraire le texte d'un fichier
def extract_text_from_file(filepath: str) -> str:
file_extension = os.path.splitext(filepath)[1].lower()
text_content = ""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Le fichier n'existe pas : {filepath}")
if file_extension == ".pdf":
try:
with open(filepath, 'rb') as f:
reader = pypdf.PdfReader(f)
for page in reader.pages:
text_content += page.extract_text() or ""
if not text_content.strip(): # Vérifie si le texte extrait est vide ou ne contient que des espaces
logger.warning(f"Le fichier PDF {filepath} a été lu mais aucun texte significatif n'a été extrait.")
except Exception as e:
logger.error(f"Erreur lors de l'extraction du texte du PDF {filepath}: {e}")
raise ValueError(f"Impossible d'extraire le texte du fichier PDF. Erreur: {e}")
elif file_extension == ".docx":
try:
document = docx.Document(filepath)
for paragraph in document.paragraphs:
text_content += paragraph.text + "\n"
if not text_content.strip():
logger.warning(f"Le fichier DOCX {filepath} a été lu mais aucun texte significatif n'a été extrait.")
except Exception as e:
logger.error(f"Erreur lors de l'extraction du texte du DOCX {filepath}: {e}")
raise ValueError(f"Impossible d'extraire le texte du fichier DOCX. Erreur: {e}")
else: # Tente de lire comme un fichier texte
try:
with open(filepath, 'r', encoding='utf-8') as f:
text_content = f.read()
except UnicodeDecodeError:
# Si UTF-8 échoue, tente latin-1
try:
with open(filepath, 'r', encoding='latin-1') as f:
text_content = f.read()
except Exception as e:
logger.error(f"Erreur lors de la lecture du fichier texte {filepath} avec UTF-8 et Latin-1: {e}")
raise ValueError(f"Impossible de lire le fichier texte (problème d'encodage). Erreur: {e}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la lecture du fichier texte {filepath}: {e}")
raise ValueError(f"Impossible de lire le fichier texte. Erreur: {e}")
return text_content
@router.post("/analyze-job-offer-and-cv", summary="Analyse la pertinence d'un CV pour une offre d'emploi", response_model=dict)
async def analyze_job_offer_and_cv_route(
request: AnalyzeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Analyse la pertinence d'un CV par rapport à une offre d'emploi en utilisant l'IA.
Prend en entrée soit les textes bruts, soit les IDs des documents.
"""
cv_text_to_analyze: Optional[str] = request.cv_text
if request.cv_id:
cv_document: Optional[Document] = crud_document.get_document_by_id(db, request.cv_id, current_user.id)
if not cv_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="CV non trouvé ou non accessible par cet utilisateur.")
try:
# Utilise la nouvelle fonction d'extraction de texte
cv_text_to_analyze = extract_text_from_file(cv_document.filepath)
if not cv_text_to_analyze.strip(): # Vérifier après extraction si le contenu est vide
raise ValueError("Le fichier CV est vide ou l'extraction de texte a échoué.")
except FileNotFoundError as e:
logger.error(f"Fichier CV introuvable: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Fichier CV introuvable sur le serveur: {e}")
except ValueError as e:
logger.error(f"Erreur lors de l'extraction/lecture du CV {cv_document.filepath}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la lecture ou de l'extraction du CV: {e}")
except Exception as e:
logger.error(f"Erreur inattendue lors du traitement du CV {cv_document.filepath}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur interne lors du traitement du CV: {e}")
# Le reste du code pour l'offre d'emploi reste inchangé
job_offer_text_to_analyze: Optional[str] = request.job_offer_text
if request.france_travail_offer_id:
try:
offer_details = await france_travail_offer_service.get_offer_details(request.france_travail_offer_id)
job_offer_text_to_analyze = offer_details.description
if not job_offer_text_to_analyze:
raise ValueError("La description de l'offre France Travail est vide.")
except RuntimeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la récupération de l'offre France Travail: {e}"
)
if not job_offer_text_to_analyze:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Impossible d'obtenir le texte de l'offre d'emploi pour l'analyse."
)
if not cv_text_to_analyze:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Le texte du CV n'a pas pu être obtenu.")
try:
analysis_result = await ai_service.analyze_job_offer_and_cv(
job_offer_text=job_offer_text_to_analyze,
cv_text=cv_text_to_analyze
)
return analysis_result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# L'endpoint /score-offer-anonymous
@router.post("/score-offer-anonymous", summary="Analyse la pertinence d'un CV pour une offre d'emploi (anonyme)", response_model=dict)
async def score_offer_anonymous(
request: AnalyzeRequest,
db: Session = Depends(get_db)
):
"""
Analyse la pertinence d'un CV par rapport à une offre d'emploi sans nécessiter d'authentification.
Prend uniquement le texte de l'offre d'emploi.
"""
if not request.job_offer_text and not request.france_travail_offer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Au moins 'job_offer_text' ou 'france_travail_offer_id' doit être fourni pour l'offre d'emploi."
)
if request.cv_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Le 'cv_id' n'est pas autorisé pour les analyses anonymes."
)
job_offer_text_to_analyze: Optional[str] = request.job_offer_text
if request.france_travail_offer_id:
try:
offer_details = await france_travail_offer_service.get_offer_details(request.france_travail_offer_id)
job_offer_text_to_analyze = offer_details.description
if not job_offer_text_to_analyze:
raise ValueError("La description de l'offre France Travail est vide.")
except RuntimeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la récupération de l'offre France Travail: {e}"
)
if not job_offer_text_to_analyze:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Impossible d'obtenir le texte de l'offre d'emploi pour l'analyse."
)
if not request.cv_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="'cv_text' est requis pour l'analyse anonyme si le CV n'est pas stocké."
)
try:
analysis_result = await ai_service.analyze_job_offer_and_cv(
job_offer_text=job_offer_text_to_analyze,
cv_text=request.cv_text
)
return analysis_result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

45
backend/routers/auth.py Normal file
View file

@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from fastapi.security import OAuth2PasswordRequestForm
from datetime import timedelta
# Importations ABSOLUES
from core.database import get_db
from core.security import verify_password, create_access_token
from crud import user as crud_user # Était déjà correcte pour "crud", mais assure la cohérence
from schemas import user as schemas_user
from core.config import settings
from core.hashing import verify_password
router = APIRouter(
prefix="/auth",
tags=["Authentication"],
responses={404: {"description": "Not found"}},
)
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
@router.post("/register", response_model=schemas_user.UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(user: schemas_user.UserCreate, db: Session = Depends(get_db)):
db_user = crud_user.get_user_by_email(db, email=user.email)
if db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email déjà enregistré.")
new_user = crud_user.create_user(db=db, user=user)
return new_user
@router.post("/login", response_model=dict)
def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = crud_user.get_user_by_email(db, email=form_data.username)
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Identifiants incorrects",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

119
backend/routers/document.py Normal file
View file

@ -0,0 +1,119 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import os
import uuid # Pour générer des noms de fichiers uniques
from core.database import get_db
from core.security import create_access_token # Non utilisé directement ici mais potentiellement dans d'autres routers
from core.config import settings # Pour accéder au chemin d'upload
from crud import document as crud_document
from crud import user as crud_user # Pour récupérer l'utilisateur courant
from schemas import document as schemas_document
from schemas import user as schemas_user # Pour le modèle UserInDBBase ou UserResponse
from dependencies import get_current_user # Pour la protection des routes
router = APIRouter(
prefix="/documents",
tags=["Documents"],
responses={404: {"description": "Not found"}},
)
@router.post("/upload-cv", response_model=schemas_document.DocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_cv(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Permet à un utilisateur authentifié d'uploader un CV.
Le fichier est stocké sur le serveur et ses métadonnées sont enregistrées en base de données.
"""
if not file.filename.lower().endswith(('.pdf', '.doc', '.docx')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Seuls les fichiers PDF, DOC, DOCX sont autorisés."
)
# Créer un nom de fichier unique pour éviter les collisions et les problèmes de sécurité
unique_filename = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(settings.UPLOADS_DIR, unique_filename)
# S'assurer que le répertoire d'uploads existe
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
try:
with open(file_path, "wb") as buffer:
# Écrit le fichier par morceaux pour les gros fichiers
while content := await file.read(1024 * 1024): # Lire par blocs de 1MB
buffer.write(content)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de l'enregistrement du fichier: {e}"
)
finally:
await file.close()
# Enregistrer les métadonnées du document dans la base de données
document_data = schemas_document.DocumentCreate(filename=file.filename)
db_document = crud_document.create_document(db, document_data, file_path, current_user.id)
return db_document
@router.get("/", response_model=list[schemas_document.DocumentResponse])
def get_user_documents(
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Récupère tous les documents uploadés par l'utilisateur authentifié.
"""
documents = crud_document.get_documents_by_owner(db, current_user.id)
return documents
@router.get("/{document_id}", response_model=schemas_document.DocumentResponse)
def get_document_details(
document_id: int,
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Récupère les détails d'un document spécifique de l'utilisateur authentifié.
"""
document = crud_document.get_document_by_id(db, document_id)
if not document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document non trouvé.")
if document.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Vous n'avez pas accès à ce document.")
return document
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_document(
document_id: int,
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Supprime un document spécifique de l'utilisateur authentifié,
à la fois de la base de données et du système de fichiers.
"""
db_document = crud_document.get_document_by_id(db, document_id)
if not db_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document non trouvé.")
if db_document.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Vous n'avez pas la permission de supprimer ce document.")
# Supprimer le fichier du système de fichiers
if os.path.exists(db_document.filepath):
try:
os.remove(db_document.filepath)
except OSError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la suppression du fichier sur le serveur: {e}"
)
# Supprimer l'entrée de la base de données
crud_document.delete_document(db, document_id)
return {"message": "Document supprimé avec succès."}

View file

@ -0,0 +1,97 @@
# backend/routers/france_travail_offers.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from services.france_travail_offer_service import france_travail_offer_service
from core.security import get_current_user
from models.user import User
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/search", response_model=FranceTravailSearchResponse)
async def search_france_travail_offers(
motsCles: Optional[str] = Query(None, description="Mots-clés de recherche (ex: 'développeur full stack')"),
commune_nom_ou_code: Optional[str] = Query(None, alias="commune", description="Nom, code postal ou code INSEE de la commune"),
distance: Optional[int] = Query(10, description="Distance maximale en km autour de la commune"),
page: int = Query(0, description="Numéro de la page de résultats (commence à 0)"),
limit: int = Query(15, description="Nombre d'offres par page (max 100 pour l'API France Travail)"), # Max 100 est une limite courante pour une seule requête à l'API FT
contrat: Optional[str] = Query(None, description="Type de contrat (ex: 'CDI', 'CDD', 'MIS')"),
experience: Optional[str] = Query(None, description="Niveau d'expérience (ex: '1' pour débutant, '2' pour 1-3 ans, '3' pour >3 ans)"),
current_user: User = Depends(get_current_user)
):
"""
Recherche des offres d'emploi via l'API France Travail.
Convertit le nom de ville en code INSEE si nécessaire et gère la pagination.
Nécessite une authentification.
"""
if limit > 100: # La limite de l'API France Travail pour 'range' est souvent 150 ou 100 items par requête.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La limite de résultats par page ne peut pas dépasser 100 pour une seule requête API."
)
commune_param_for_api = None
if commune_nom_ou_code:
if commune_nom_ou_code.isdigit() and len(commune_nom_ou_code) == 5:
commune_param_for_api = commune_nom_ou_code
logger.info(f"Recherche par code postal: {commune_nom_ou_code}")
else:
logger.info(f"Tentative de récupération du code INSEE pour la ville: {commune_nom_ou_code}")
insee_code = await france_travail_offer_service.get_insee_code_for_commune(commune_nom_ou_code)
if not insee_code:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Code INSEE non trouvé pour la ville '{commune_nom_ou_code}'. Veuillez vérifier l'orthographe ou utiliser un code postal."
)
commune_param_for_api = insee_code
logger.info(f"Code INSEE '{insee_code}' trouvé pour '{commune_nom_ou_code}'.")
if (commune_param_for_api is not None) and (distance is None):
distance = 10
# Calcul du paramètre 'range' pour l'API France Travail
start_index = page * limit
end_index = start_index + limit - 1
api_range_param = f"{start_index}-{end_index}"
logger.info(f"Paramètre 'range' calculé pour l'API France Travail: {api_range_param}")
try:
response = await france_travail_offer_service.search_offers(
motsCles=motsCles,
commune=commune_param_for_api,
distance=distance,
range=api_range_param, # On passe le 'range' calculé
typeContrat=contrat,
# experience=experience # Vérifiez si ce paramètre est géré par l'API France Travail ou doit être mappé
)
return response
except RuntimeError as e:
logger.error(f"Erreur lors de la recherche d'offres France Travail: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Impossible de récupérer les offres de France Travail: {e}"
)
@router.get("/{offer_id}", response_model=OffreDetail)
async def get_france_travail_offer_details(
offer_id: str,
current_user: User = Depends(get_current_user)
):
"""
Récupère les détails d'une offre d'emploi spécifique de l'API France Travail par son ID.
Nécessite une authentification.
"""
try:
details = await france_travail_offer_service.get_offer_details(offer_id)
return details
except RuntimeError as e:
logger.error(f"Erreur lors de la récupération des détails de l'offre {offer_id} de France Travail: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Impossible de récupérer les détails de l'offre: {e}"
)

View file

View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
class AiInteractionBase(BaseModel):
job_offer_text: str
cv_text_used: Optional[str] = None
interaction_type: str = "scoring" # Valeur par défaut
class AiInteractionCreate(AiInteractionBase):
ai_request: str
ai_response: str
score: Optional[float] = None
analysis_results: Optional[str] = None
user_id: Optional[int] = None
document_id: Optional[int] = None
class AiInteractionResponse(AiInteractionCreate):
id: int
created_at: datetime
class Config:
from_attributes = True

View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, Field
from datetime import datetime
class DocumentBase(BaseModel):
filename: str
class DocumentCreate(DocumentBase):
# Pas besoin de filepath ici, il sera généré par le backend
pass
class DocumentResponse(DocumentBase):
id: int
filepath: str
owner_id: int
uploaded_at: datetime
class Config:
from_attributes = True
class DocumentDeleteResponse(BaseModel):
detail: str
filename: str
id: int

View file

@ -0,0 +1,110 @@
# backend/schemas/france_travail.py
from datetime import datetime
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, field_validator, computed_field
# Modèles de données pour les structures communes (Lieu, Entreprise, etc.)
class LieuTravail(BaseModel):
libelle: Optional[str] = Field(None, example="Paris")
codePostal: Optional[str] = Field(None, example="75001")
commune: Optional[str] = Field(None, example="Paris")
class TypeContrat(BaseModel):
code: Optional[str] = Field(None, example="CDI")
libelle: Optional[str] = Field(None, example="Contrat à durée indéterminée")
class Appellation(BaseModel):
code: Optional[str] = Field(None, example="10034")
libelle: Optional[str] = Field(None, example="Développeur informatique")
class OrigineOffre(BaseModel):
url: Optional[str] = Field(None, example="https://candidat.francetravail.fr/candidature/offre/1234567")
typeOrigine: Optional[str] = Field(None, example="ONLINE")
class Entreprise(BaseModel):
nom: Optional[str] = Field(None, example="Ma Super Entreprise")
description: Optional[str] = None
url: Optional[str] = None
id: Optional[str] = None
class Salaire(BaseModel):
libelle: Optional[str] = Field(None, example="2500 EUR brut/mois")
commentaire: Optional[str] = None
typeForfait: Optional[str] = None
periode: Optional[str] = None
min: Optional[float] = None
max: Optional[float] = None
class Competence(BaseModel):
code: Optional[str] = None
libelle: Optional[str] = None
description: Optional[str] = None
exigence: Optional[str] = None
class Experience(BaseModel):
libelle: Optional[str] = Field(None, example="Débutant accepté")
code: Optional[str] = None
class Formation(BaseModel):
domaineLibelle: Optional[str] = None
niveaulibelle: Optional[str] = None
codeFormation: Optional[str] = None
class Permis(BaseModel):
libelle: Optional[str] = None
code: Optional[str] = None
# Modèle pour une offre individuelle
class Offre(BaseModel):
id: str = Field(..., example="1234567")
intitule: str = Field(..., example="Développeur Full Stack")
description: Optional[str] = None
dateCreation: datetime
dateActualisation: datetime
lieuTravail: Optional[LieuTravail] = None
typeContrat: Optional[Union[TypeContrat, str]] = None
romeCode: Optional[str] = None
romeLibelle: Optional[str] = None
appellationLibelle: Optional[str] = None
entreprise: Optional[Entreprise] = None
origineOffre: Optional[OrigineOffre] = None
nbPostes: Optional[int] = None
nbResultats: Optional[int] = None
@field_validator('typeContrat', mode='before')
@classmethod
def validate_type_contrat(cls, v: Any) -> Any:
if isinstance(v, str):
return TypeContrat(code=v, libelle=None)
return v
class Config:
from_attributes = True
# AJOUTEZ CETTE PROPRIÉTÉ CALCULÉE
@computed_field
def url_francetravail(self) -> str:
"""Génère l'URL de l'offre sur le site candidat.francetravail.fr."""
return f"https://candidat.francetravail.fr/offres/recherche/detail/{self.id}"
# Modèle pour les détails complets d'une offre
class OffreDetail(Offre):
# OffreDetail hérite de Offre, donc il aura automatiquement la propriété url_francetravail
description: str = Field(..., example="Description détaillée du poste...")
complementExercice: Optional[str] = None
urlDossierCandidature: Optional[str] = None # Ce champ vient directement de l'API s'il est fourni
qualification: Optional[str] = None
appellations: Optional[List[Appellation]] = None
competences: Optional[List[Competence]] = None
entreprise: Optional[Entreprise] = None
formations: Optional[List[Formation]] = None
langues: Optional[List[Dict[str, Any]]] = None
permis: Optional[List[Permis]] = None
class Config:
from_attributes = True
class FranceTravailSearchResponse(BaseModel):
resultats: List[Offre] = Field(default_factory=list)
totalResults: Optional[int] = Field(None, description="Nombre total d'offres correspondant aux critères")
range: Optional[str] = Field(None, description="Plage des résultats actuels, ex: '0-14/100'")

10
backend/schemas/token.py Normal file
View file

@ -0,0 +1,10 @@
# backend/schemas/token.py
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None

23
backend/schemas/user.py Normal file
View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str
name: Optional[str] = None # Conforme au PRD: nom/prénom optionnels
class UserLogin(UserBase):
password: str
class UserResponse(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: datetime
name: Optional[str] = None
class Config:
from_attributes = True # Ancien orm_mode = True pour Pydantic v2+

View file

View file

@ -0,0 +1,184 @@
import json
import logging
import sys
from typing import Optional, Dict, Any
from google import genai
from google.genai import types
import mistralai
from mistralai.client import MistralClient
from fastapi import HTTPException, status
import anyio # <-- NOUVELLE IMPORTATION : Pour gérer les appels synchrones dans async
from core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- DEBUGGING PRINTS ---
try:
logger.info(f"Loaded mistralai package from: {mistralai.__file__}")
logger.info(f"mistralai package version: {mistralai.__version__}")
if hasattr(MistralClient, '__module__'):
logger.info(f"MistralClient class module: {MistralClient.__module__}")
client_module = sys.modules.get(MistralClient.__module__)
if client_module and hasattr(client_module, '__file__'):
logger.info(f"MistralClient class file: {client_module.__file__}")
except Exception as e:
logger.error(f"Error during mistralai debug info collection: {e}")
class AIService:
def __init__(self):
self.provider = settings.LLM_PROVIDER
self.model_name = settings.GEMINI_MODEL_NAME if self.provider == "gemini" else settings.MISTRAL_MODEL_NAME
self.raw_safety_settings = [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
},
]
self.raw_generation_config = {
"temperature": 0.7,
"top_p": 1,
"top_k": 1,
}
if self.provider == "gemini":
try:
self.client = genai.Client(api_key=settings.GEMINI_API_KEY)
self.gemini_config = types.GenerateContentConfig(
temperature=self.raw_generation_config["temperature"],
top_p=self.raw_generation_config["top_p"],
top_k=self.raw_generation_config["top_k"],
safety_settings=[
types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in self.raw_safety_settings
]
)
except Exception as e:
logger.error(f"Erreur d'initialisation du client Gemini: {e}")
raise ValueError(f"Impossible d'initialiser le client Gemini. Vérifiez votre GEMINI_API_KEY. Erreur: {e}")
elif self.provider == "mistral":
if not settings.MISTRAL_API_KEY:
raise ValueError("MISTRAL_API_KEY n'est pas configurée dans les paramètres.")
self.client = MistralClient(api_key=settings.MISTRAL_API_KEY)
else:
raise ValueError(f"Fournisseur LLM non supporté: {self.provider}")
logger.info(f"AI Service initialized with Provider: {self.provider}, Model: {self.model_name}")
async def analyze_job_offer_and_cv(self, job_offer_text: str, cv_text: str) -> Dict[str, Any]:
prompt = f"""
En tant qu'assistant spécialisé dans la rédaction de CV et de lettres de motivation, votre tâche est d'analyser une offre d'emploi et un CV fournis, puis de :
1. Calculer un score de pertinence entre 0 et 100 indiquant à quel point le CV correspond à l'offre.
2. Identifier les 3 à 5 points forts du CV en relation avec l'offre.
3. Suggérer 3 à 5 améliorations clés pour le CV afin de mieux correspondre à l'offre.
4. Proposer une brève phrase d'accroche pour une lettre de motivation, personnalisée pour cette offre et ce CV.
5. Identifier 3 à 5 mots-clés ou phrases importants de l'offre d'emploi que l'on devrait retrouver dans le CV.
L'offre d'emploi est la suivante :
---
{job_offer_text}
---
Le CV est le suivant :
---
{cv_text}
---
Veuillez retourner votre analyse au format JSON, en respectant la structure suivante :
{{
"score_pertinence": int,
"points_forts": ["string", "string", ...],
"ameliorations_cv": ["string", "string", ...],
"phrase_accroche_lm": "string",
"mots_cles_offre": ["string", "string", ...]
}}
"""
response_content = ""
if self.provider == "gemini":
try:
contents = [
{"role": "user", "parts": [{"text": prompt}]}
]
# MODIFIÉ ICI : Utilisation de anyio.to_thread.run_sync pour l'appel synchrone
response = await anyio.to_thread.run_sync(
self.client.models.generate_content,
model=self.model_name,
contents=contents,
config=self.gemini_config,
)
response_content = response.text
# Nettoyage de la réponse pour retirer les blocs de code Markdown
if response_content.startswith("```json") and response_content.endswith("```"):
response_content = response_content[len("```json"): -len("```")].strip()
elif response_content.startswith("```") and response_content.endswith("```"):
response_content = response_content[len("```"): -len("```")].strip()
except Exception as e:
logger.error(f"Erreur lors de l'appel à Gemini: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de l'appel à l'API Gemini: {e}"
)
elif self.provider == "mistral":
if not settings.MISTRAL_API_KEY:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="La clé API Mistral n'est pas configurée."
)
try:
response = await self.client.chat_async(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_tokens=1000
)
response_content = response.choices[0].message.content
except Exception as e:
logger.error(f"Erreur lors de l'appel à Mistral: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de l'appel à l'API Mistral: {e}"
)
else:
raise ValueError(f"Fournisseur LLM non supporté: {self.provider}")
logger.info(f"Réponse brute de l'IA (après nettoyage si nécessaire) ({self.provider}): {response_content}")
try:
parsed_response = json.loads(response_content)
return parsed_response
except json.JSONDecodeError as e:
logger.error(f"Erreur de décodage JSON de la réponse IA ({self.provider}): {e}")
logger.error(f"Contenu non-JSON reçu (après nettoyage): {response_content}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="La réponse de l'IA n'était pas au format JSON attendu."
)
# Instanciation unique du service AI
ai_service = AIService()

View file

@ -0,0 +1,68 @@
# backend/services/france_travail_auth_service.py
import httpx
import logging
from core.config import settings
logger = logging.getLogger(__name__)
class FranceTravailAuthService:
_instance = None
_token_cache = {} # Cache pour stocker le token
def __new__(cls):
if cls._instance is None:
cls._instance = super(FranceTravailAuthService, cls).__new__(cls)
return cls._instance
async def get_access_token(self):
# Vérifiez si le token est encore valide dans le cache
if self._token_cache and self._token_cache.get("expires_at", 0) > httpx._compat.current_time():
logger.info("Utilisation du token France Travail depuis le cache.")
return self._token_cache["access_token"]
logger.info("Obtention d'un nouveau token France Travail...")
token_url = settings.FRANCE_TRAVAIL_TOKEN_URL
client_id = settings.FRANCE_TRAVAIL_CLIENT_ID
client_secret = settings.FRANCE_TRAVAIL_CLIENT_SECRET
scope = "o2dsoffre api_offresdemploiv2" # Assurez-vous que ces scopes sont activés pour votre application
data = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope
}
headers = {
"Content-Type": "application/x-www-form-urlencoded" # C'est très important !
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(token_url, data=data, headers=headers)
response.raise_for_status() # Lève une exception pour les codes d'erreur HTTP
token_data = response.json()
access_token = token_data.get("access_token")
expires_in = token_data.get("expires_in") # Durée de validité en secondes
if not access_token:
raise ValueError("Le token d'accès n'a pas été trouvé dans la réponse de France Travail.")
# Mettre à jour le cache
self._token_cache = {
"access_token": access_token,
"expires_at": httpx._compat.current_time() + expires_in - 60 # 60 secondes de marge de sécurité
}
logger.info("Nouveau token France Travail obtenu et mis en cache.")
return access_token
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'obtention du token France Travail: {e.response.status_code} - {e.response.text}")
# Re-raise une RuntimeError pour que le service appelant puisse la gérer
raise RuntimeError(f"Erreur d'authentification France Travail: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de l'obtention du token France Travail: {e}")
raise RuntimeError(f"Erreur inattendue lors de l'obtention du token France Travail: {e}")
france_travail_auth_service = FranceTravailAuthService()

View file

@ -0,0 +1,197 @@
# backend/services/france_travail_offer_service.py
import httpx
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any, Union
from core.config import settings
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre, TypeContrat
logger = logging.getLogger(__name__)
class FranceTravailOfferService:
def __init__(self):
self.client_id = settings.FRANCE_TRAVAIL_CLIENT_ID
self.client_secret = settings.FRANCE_TRAVAIL_CLIENT_SECRET
self.token_url = settings.FRANCE_TRAVAIL_TOKEN_URL
self.api_base_url = settings.FRANCE_TRAVAIL_API_BASE_URL
self.api_scope = settings.FRANCE_TRAVAIL_API_SCOPE
self.access_token = None
self.token_expires_at = None
async def _get_access_token(self):
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
logger.info("Réutilisation du token France Travail existant.")
return self.access_token
logger.info("Obtention d'un nouveau token d'accès France Travail...")
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": self.api_scope
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(self.token_url, headers=headers, data=data)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 1500)
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in - 60)
logger.info("Token France Travail obtenu avec succès.")
return self.access_token
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'obtention du token France Travail: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de l'obtention du token France Travail: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de l'obtention du token France Travail: {e}")
raise RuntimeError(f"Échec inattendu lors de l'obtention du token France Travail: {e}")
async def get_insee_code_for_commune(self, commune_name: str) -> Optional[str]:
"""
Récupère le code INSEE d'une commune à partir de son nom.
Recherche une correspondance exacte du libellé, ou un code spécifique pour Paris.
"""
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"q": commune_name
}
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.api_base_url}/v2/referentiel/communes",
headers=headers,
params=params
)
response.raise_for_status()
communes_data = response.json()
found_code = None
normalized_input_name = commune_name.upper().strip()
if communes_data and isinstance(communes_data, list):
for commune_info in communes_data:
if commune_info and "code" in commune_info and "libelle" in commune_info:
normalized_libelle = commune_info["libelle"].upper().strip()
# Priorité 1: Recherche spécifique pour "PARIS" avec son code INSEE connu
if normalized_input_name == "PARIS" and commune_info["code"] == "75056":
found_code = commune_info["code"]
break
# Priorité 2: Correspondance exacte du libellé
elif normalized_libelle == normalized_input_name:
found_code = commune_info["code"]
break
# Priorité 3: Si c'est Paris, mais le libellé renvoyé n'est pas "PARIS" exactement,
# mais le code est le bon, on le prend quand même.
# Ceci peut arriver si l'API renvoie "Paris 01" par exemple.
elif normalized_input_name == "PARIS" and commune_info["code"] in ["75056", "75101", "75102", "75103", "75104", "75105", "75106", "75107", "75108", "75109", "75110", "75111", "75112", "75113", "75114", "75115", "75116", "75117", "75118", "75119", "75120"]:
# Note: Les codes 75101 à 75120 sont pour les arrondissements, mais l'API
# France Travail utilise souvent le 75056 pour "Paris" globalement.
# Cette condition est plus une sécurité, mais 75056 est la cible principale.
if commune_info["code"] == "75056": # On préfère le code global de Paris
found_code = commune_info["code"]
break
elif found_code is None: # Si on n'a pas encore trouvé 75056, on prend un arrondissement
found_code = commune_info["code"] # Conserver le code d'arrondissement si c'est le seul "Paris" trouvé
# Note: La logique ici est à affiner selon si vous voulez les arrondissements ou seulement le code global.
# Pour la plupart des cas, "75056" est suffisant.
if found_code:
logger.info(f"Code INSEE pour '{commune_name}' trouvé : {found_code}")
return found_code
logger.warning(f"Aucun code INSEE exact trouvé pour la commune '{commune_name}' parmi les résultats de l'API. Vérifiez l'orthographe.")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la récupération du code INSEE pour '{commune_name}': {e.response.status_code} - {e.response.text}")
return None
except Exception as e:
logger.error(f"Erreur inattendue lors de la récupération du code INSEE pour '{commune_name}': {e}")
return None
async def search_offers(self,
motsCles: Optional[str] = None,
typeContrat: Optional[str] = None,
codePostal: Optional[str] = None,
commune: Optional[str] = None,
distance: Optional[int] = None,
alternance: Optional[bool] = None,
offresManagerees: Optional[bool] = None,
range: str = "0-14") -> FranceTravailSearchResponse:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"range": range,
}
if motsCles:
params["motsCles"] = motsCles
if typeContrat:
params["typeContrat"] = typeContrat
if alternance is not None:
params["alternance"] = str(alternance).lower()
if offresManagerees is not None:
params["offresManagerees"] = str(offresManagerees).lower()
if codePostal:
params["codePostal"] = codePostal
if distance is not None:
params["distance"] = distance
else:
params["distance"] = 10
elif commune:
params["commune"] = commune
if distance is not None:
params["distance"] = distance
else:
params["distance"] = 10
logger.info(f"Paramètres de recherche France Travail: {params}")
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{self.api_base_url}/v2/offres/search", headers=headers, params=params)
response.raise_for_status()
return FranceTravailSearchResponse(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la recherche d'offres France Travail: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de la recherche d'offres France Travail: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la recherche d'offres France Travail: {e}")
raise RuntimeError(f"Échec inattendu lors de la recherche d'offres France Travail: {e}")
async def get_offer_details(self, offer_id: str) -> OffreDetail:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{self.api_base_url}/v2/offres/{offer_id}", headers=headers)
response.raise_for_status()
return OffreDetail(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la récupération des détails de l'offre {offer_id}: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de la récupération des détails de l'offre {offer_id}: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la récupération des détails de l'offre {offer_id}: {e}")
raise RuntimeError(f"Échec inattendu lors de la récupération des détails de l'offre {offer_id}: {e}")
france_travail_offer_service = FranceTravailOfferService()

110
documentation/PRD.md Normal file
View file

@ -0,0 +1,110 @@
# PRD - Product Requirements Document - Allons-y Assistant de Candidature IA
## 1. Introduction
Ce Product Requirements Document (PRD) détaille les objectifs, les fonctionnalités et les exigences techniques du projet "Allons-y - Assistant de Candidature IA". Il vise à fournir une compréhension claire et partagée du produit, de ses utilisateurs et des spécifications nécessaires à son développement.
## 2. Objectifs du Produit
* **Objectif Principal :** Simplifier et optimiser le processus de candidature pour les demandeurs d'emploi grâce à l'intelligence artificielle.
* **Objectifs Spécifiques :**
* Réduire le temps passé par les candidats à adapter leurs documents.
* Augmenter la pertinence des candidatures par rapport aux offres.
* Fournir des retours constructifs pour améliorer les documents (CV, LM).
* Offrir une expérience utilisateur intuitive et accessible.
* Permettre une découverte rapide du produit sans barrière d'inscription.
## 3. Utilisateurs Cibles
* Toute personne en recherche d'emploi, du jeune diplômé au professionnel expérimenté.
* Utilisateurs cherchant à optimiser leur temps et l'efficacité de leurs candidatures.
## 4. Portée du MVP (Minimum Viable Product) - Phase 1
Le MVP se concentrera sur la fonctionnalité la plus valorisante et la plus différenciante : **l'Analyse d'Offre (Scoring)**, combinée à une gestion de base des utilisateurs et des documents.
### 4.1 Fonctionnalités du MVP
* **Gestion de l'Authentification :**
* Inscription d'un nouvel utilisateur (email, mot de passe, nom/prénom optionnels).
* Connexion / Déconnexion (basée sur JWT).
* Gestion du profil utilisateur (affichage, modification basique).
* **Gestion des Documents Utilisateur :**
* Upload d'un CV par l'utilisateur connecté (formats PDF, DOCX).
* Stockage sécurisé du CV sur le backend (avec lien en DB).
* **Fonctionnalité IA : Analyse d'Offre (Scoring) :**
* **Pour utilisateur connecté :** L'utilisateur colle le descriptif de l'offre et sélectionne un CV déjà uploadé.
* **Pour utilisateur non connecté (Anonyme) :** L'utilisateur colle le descriptif de l'offre et uploade temporairement un CV.
* L'IA (Gemini/Mistral) analyse le descriptif de l'offre et le CV.
* Résultat : Un score de pertinence (ex: 1-100%) et des points clés textuels justifiant le score (ex: "Compétences manquantes", "Mots-clés pertinents trouvés", "Expériences similaires").
* Limitation du nombre de requêtes pour les utilisateurs anonymes (ex: 2 requêtes/session ou par IP).
* **Interface Utilisateur :**
* Page d'accueil invitant à s'inscrire/se connecter ou à essayer l'analyse d'offre.
* Formulaire simple pour l'analyse d'offre.
* Tableau de bord utilisateur basique avec les actions rapides et un aperçu des documents.
* Messages d'erreur et de succès clairs.
## 5. Exigences Fonctionnelles Détaillées (MVP)
* **FE-AUTH-001 :** L'utilisateur doit pouvoir s'inscrire avec une adresse email et un mot de passe.
* **FE-AUTH-002 :** L'utilisateur doit pouvoir se connecter avec son email et son mot de passe.
* **FE-AUTH-003 :** L'utilisateur doit être redirigé vers le tableau de bord après une connexion réussie.
* **FE-PROFILE-001 :** L'utilisateur connecté doit voir son nom ou email sur le tableau de bord.
* **FE-FILE-001 :** L'utilisateur connecté doit pouvoir uploader un fichier (CV) via un bouton dédié.
* **FE-FILE-002 :** L'upload doit supporter les formats PDF et DOCX.
* **FE-IA-ANALYSE-001 :** L'utilisateur doit pouvoir accéder au formulaire d'analyse d'offre.
* **FE-IA-ANALYSE-002 :** Le formulaire doit inclure un champ de texte pour le descriptif de poste.
* **FE-IA-ANALYSE-003 :** Le formulaire doit inclure une option pour sélectionner un CV uploadé (pour connecté) ou uploader un CV temporaire (pour anonyme).
* **FE-IA-ANALYSE-004 :** Après soumission, un indicateur de chargement doit apparaître.
* **FE-IA-ANALYSE-005 :** Le résultat de l'analyse (score, texte d'analyse) doit être affiché de manière claire.
* **FE-ANONYMOUS-001 :** Un utilisateur non connecté doit pouvoir accéder au formulaire d'analyse d'offre sans s'inscrire.
* **FE-ANONYMOUS-002 :** Après l'analyse anonyme, une invitation à s'inscrire doit être affichée.
* **BE-AUTH-001 :** Le backend doit valider les informations d'inscription (email unique, mot de passe fort).
* **BE-AUTH-002 :** Le backend doit hasher les mots de passe avant stockage en DB.
* **BE-AUTH-003 :** Le backend doit générer un JWT après connexion réussie.
* **BE-FILE-001 :** Le backend doit gérer l'upload de fichiers, les stocker et enregistrer leurs métadonnées en DB.
* **BE-FILE-002 :** Le backend doit extraire le texte des fichiers PDF/DOCX pour l'IA.
* **BE-IA-ANALYSE-001 :** L'endpoint d'analyse d'offre doit recevoir le descriptif et le CV.
* **BE-IA-ANALYSE-002 :** Le backend doit construire un prompt optimisé pour l'IA (Gemini/Mistral) intégrant l'offre et le CV.
* **BE-IA-ANALYSE-003 :** Le backend doit appeler l'API IA et gérer ses réponses (parsing, erreurs).
* **BE-IA-ANALYSE-004 :** Le backend doit stocker les requêtes IA et leurs résultats en DB (avec ou sans `user_id`).
* **BE-ANONYMOUS-001 :** Le backend doit implémenter un mécanisme de limitation de débit (rate limiting) pour les requêtes anonymes.
## 6. Exigences Non Fonctionnelles (MVP)
* **Performance :**
* Temps de réponse de l'API IA : < 5 secondes (hors temps de l'API externe).
* Temps de chargement des pages : < 3 secondes.
* **Sécurité :**
* Mots de passe hachés.
* Utilisation de HTTPS en production.
* Protection contre les injections SQL, XSS, CSRF.
* Stockage sécurisé des clés API.
* Gestion des fichiers uploadés de manière sécurisée (permissions, noms de fichiers uniques).
* **Scalabilité :**
* Architecture permettant d'ajouter de nouvelles fonctionnalités IA.
* Base de données PostgreSQL pour la scalabilité des données.
* **Fiabilité :**
* Gestion des erreurs robuste pour les appels API (backend et IA).
* Journalisation (logging) des événements importants.
* **Expérience Utilisateur :**
* Interface propre, intuitive et réactive.
* Messages de retour clairs et amicaux.
## 7. Critères d'Acceptation du MVP
Le MVP sera considéré comme achevé lorsque :
* Les utilisateurs peuvent s'inscrire, se connecter et gérer leur profil.
* Les utilisateurs connectés peuvent uploader et stocker leurs CVs.
* La fonctionnalité d'Analyse d'Offre (scoring) est entièrement opérationnelle pour les utilisateurs connectés et anonymes (avec limitation).
* Les résultats de l'analyse d'offre sont pertinents et bien présentés.
* L'application est stable et ne présente pas d'erreurs majeures.
* Les exigences de sécurité de base sont respectées.
## 8. Futures Itérations / Fonctionnalités Post-MVP
Voir le document `ROADMAP.md` pour les détails des phases futures.
---

83
documentation/ROADMAP.md Normal file
View file

@ -0,0 +1,83 @@
# ROADMAP - Allons-y Assistant de Candidature IA
Ce document décrit la feuille de route du projet Allons-y Assistant de Candidature IA, décomposée en phases pour une implémentation progressive. Il servira de guide pour le développement et la priorisation des fonctionnalités.
## Vision Générale
Rendre le processus de candidature plus efficace et accessible grâce à l'IA, en fournissant des outils intuitifs pour l'optimisation des documents et l'adaptation aux offres d'emploi.
## Phases de Développement
### Phase 1 : Minimum Viable Product (MVP) - (Focus sur l'Essentiel et la Découverte)
**Objectif :** Valider le concept principal avec les fonctionnalités IA les plus critiques et permettre une première prise en main de l'application, même sans inscription.
* **Frontend :**
* Page d'accueil simple avec CTA pour l'inscription/connexion et l'essai anonyme.
* Interface pour l'**Analyse d'Offre (Scoring)** :
* Champ de texte pour coller le descriptif de poste.
* Option d'upload temporaire d'un CV (pour l'analyse anonyme) ou sélection d'un CV déjà uploadé (pour utilisateur connecté).
* Affichage clair du score de pertinence et des points clés d'amélioration/correspondance.
* Pages d'Inscription et de Connexion.
* Tableau de bord utilisateur basique (après connexion) :
* Affichage du nom de l'utilisateur.
* Section pour uploader un CV.
* Accès à la fonctionnalité d'Analyse d'Offre.
* Gestion basique des erreurs et chargements.
* **Backend :**
* API RESTful pour l'authentification (inscription, connexion avec JWT).
* Gestion des utilisateurs et de leurs données de base en PostgreSQL.
* Endpoint pour l'upload de CVs (sauvegarde locale/S3, entrée en DB).
* Endpoint pour l'**Analyse d'Offre (Scoring)** :
* Réception du texte de l'offre et du contenu du CV (après extraction).
* Préparation du prompt pour l'API IA (Gemini/Mistral).
* Appel à l'API IA.
* Traitement et renvoi du résultat formaté.
* Gestion des interactions anonymes (limitation de requêtes par IP/session).
* Gestion sécurisée des clés API IA et des fichiers.
* Modèles de base de données : `User`, `File`, `AiInteraction` (simplifié pour le scoring).
* **Documentation :**
* `README.md` (racine, frontend, backend) complétés.
* `ROADMAP.md` (ce document).
* `PRD.md` (détaillé pour la Phase 1).
### Phase 2 : Enrichissement des Outils IA et Expérience Utilisateur
**Objectif :** Développer les autres fonctionnalités IA clés et améliorer l'expérience utilisateur, notamment la gestion des documents et l'historique.
* **Frontend :**
* Interface pour la **Génération de Paragraphe de Motivation**.
* Interface pour la **Reformulation de Compétences**.
* Interface pour les **Questions d'Entretien**.
* Section "Mes documents" sur le tableau de bord : Lister les CVs/LMs uploadés, permettre la suppression, prévisualisation basique.
* Page "Historique des Requêtes" pour les utilisateurs connectés.
* **Backend :**
* Endpoints pour les nouvelles fonctionnalités IA (paragraphe, reformulation, questions).
* Amélioration de la gestion des fichiers (URL pré-signées pour le téléchargement).
* Enrichissement du modèle `AiInteraction` pour stocker toutes les requêtes.
* Logique pour l'extraction de texte à partir de PDF/DOCX (si non déjà robuste).
* **Documentation :**
* Mise à jour du `PRD.md` avec les nouvelles fonctionnalités.
### Phase 3 : Optimisation et Fonctionnalités Avancées
**Objectif :** Ajouter des outils plus spécifiques, améliorer la performance et la fiabilité, et explorer de nouvelles opportunités.
* **Frontend :**
* Interface pour la **Génération de Mots-clés CV**.
* Tableau de bord plus riche : statistiques d'utilisation, suggestions proactives.
* Notifications (ex: "Votre analyse est prête").
* Amélioration de l'UX (animations, feedback utilisateur).
* **Backend :**
* Endpoint pour les Mots-clés CV.
* Optimisation des appels IA (caching si pertinent, gestion des erreurs avancée).
* Mise en place de tests d'intégration et de performance.
* Monitoring de l'utilisation des APIs.
* **Potentiel Futur :**
* Exportation des résultats (ex: générer une lettre de motivation complète en PDF).
* Intégration avec des plateformes d'emploi (LinkedIn, Indeed - nécessite une étude approfondie).
* Fonctionnalités collaboratives (partage de candidatures).
* Modèles d'IA personnalisables (pour utilisateurs premium).
* Interface pour la gestion des offres d'emploi suivies (comme sur le screenshot de dashboard).
---