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

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()