backend
This commit is contained in:
commit
d7666f7b2c
44 changed files with 2246 additions and 0 deletions
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
184
backend/services/ai_service.py
Normal file
184
backend/services/ai_service.py
Normal 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()
|
68
backend/services/france_travail_auth_service.py
Normal file
68
backend/services/france_travail_auth_service.py
Normal 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()
|
197
backend/services/france_travail_offer_service.py
Normal file
197
backend/services/france_travail_offer_service.py
Normal 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()
|
Loading…
Add table
Add a link
Reference in a new issue