departements

This commit is contained in:
el 2025-07-01 18:25:10 +02:00
parent 6b53a419c9
commit 4c180fe1f8
19 changed files with 21999 additions and 431 deletions

View file

@ -1,11 +1,12 @@
import json
import logging
import sys
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
import google.genai as genai
# CORRECTION ICI : Importez explicitement HarmCategory et HarmBlockThreshold
from google.genai import types, HarmCategory, HarmBlockThreshold # Pour accéder à GenerationConfig, HarmCategory, HarmBlockThreshold
# MODIFIÉ ICI: Importations pour google-genai
from google import genai
from google.genai import types # Nécessaire pour configurer les types comme GenerateContentConfig
import mistralai
from mistralai.client import MistralClient
@ -13,6 +14,7 @@ from fastapi import HTTPException, status
import anyio
from core.config import settings
from services.romeo_service import romeo_service # Assurez-vous que ce service existe et est configuré
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@ -29,140 +31,208 @@ try:
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.client = None # Initialise le client à None
self.model = None # Initialise l'instance du modèle à None
# S'assurer que generation_config et safety_settings sont toujours définis
self.generation_config = types.GenerationConfig(
candidate_count=1,
max_output_tokens=2048,
temperature=0.7,
top_k=40,
top_p=0.95
)
self.safety_settings = [
{"category": HarmCategory.HARM_CATEGORY_HARASSMENT, "threshold": HarmBlockThreshold.BLOCK_NONE},
{"category": HarmCategory.HARM_CATEGORY_HATE_SPEECH, "threshold": HarmBlockThreshold.BLOCK_NONE},
{"category": HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, "threshold": HarmBlockThreshold.BLOCK_NONE},
{"category": HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, "threshold": HarmBlockThreshold.BLOCK_NONE},
]
if self.provider == "gemini":
try:
# Initialisation du client genai.Client()
self.client = genai.Client(
api_key=settings.GEMINI_API_KEY
)
logger.info(f"Client Gemini genai.Client() initialisé.")
# Configuration de la génération avec types.GenerateContentConfig
self.gemini_config = types.GenerateContentConfig(
temperature=0.7,
top_p=1.0,
top_k=1,
safety_settings=[
types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"),
types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"),
types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"),
types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"),
]
)
logger.info(f"Configuration Gemini types.GenerateContentConfig créée.")
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}")
if not settings.GEMINI_API_KEY:
raise ValueError("GEMINI_API_KEY n'est pas configurée.")
genai.configure(api_key=settings.GEMINI_API_KEY)
self.client = genai # Ceci est l'API de base
# Créez une instance de modèle spécifique sur laquelle appeler generate_content_async
self.model = genai.GenerativeModel(self.model_name, generation_config=self.generation_config)
elif self.provider == "mistral":
if not settings.MISTRAL_API_KEY:
raise ValueError("MISTRAL_API_KEY n'est pas configurée dans les paramètres.")
raise ValueError("MISTRAL_API_KEY n'est pas configurée.")
# Initialize Mistral client
self.client = MistralClient(api_key=settings.MISTRAL_API_KEY)
# Pour Mistral, le client est directement l'objet qui appelle le chat, pas un modèle séparé comme pour Gemini.
# Gardez self.model à None ou à une valeur non utilisée si vous ne l'utilisez pas avec Mistral.
else:
raise ValueError(f"Fournisseur LLM non supporté: {self.provider}")
raise ValueError(f"Fournisseur LLM inconnu: {self.provider}")
logger.info(f"AI Service initialized with Provider: {self.provider}, Model: {self.model_name}")
async def _call_gemini_api(self, prompt: str) -> str:
try:
# CORRECTION ICI: Utilisez self.model pour appeler generate_content_async
if not self.model: # Ajout d'une vérification pour s'assurer que le modèle est initialisé
raise ValueError("Le modèle Gemini n'a pas été correctement initialisé.")
response = await self.model.generate_content_async(
prompt,
generation_config=self.generation_config,
safety_settings=self.safety_settings
)
return response.text
except Exception as e:
logger.error(f"Erreur lors de l'appel à Gemini: {e}")
raise RuntimeError(f"Erreur lors de l'appel à Gemini: {e}")
async def analyze_job_offer_and_cv(self, job_offer_text: str, cv_text: str) -> Dict[str, Any]:
async def _call_mistral_api(self, prompt: str) -> str:
try:
# Assurez-vous que self.client est bien un MistralClient
if not isinstance(self.client, MistralClient):
raise TypeError("Le client Mistral n'est pas correctement initialisé.")
response = self.client.chat(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=self.generation_config.temperature,
max_tokens=self.generation_config.max_output_tokens,
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"Erreur lors de l'appel à Mistral: {e}")
raise RuntimeError(f"Erreur lors de l'appel à Mistral: {e}")
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.
En tant qu'expert en recrutement, j'ai besoin d'une analyse comparative détaillée entre une offre d'emploi et un CV.
L'analyse doit identifier les correspondances, les lacunes et les suggestions d'amélioration pour le CV, en vue de maximiser les chances d'obtenir le poste.
L'offre d'emploi est la suivante :
Voici l'offre d'emploi :
---
{job_offer_text}
---
Le CV est le suivant :
Voici le CV :
---
{cv_text}
---
Veuillez retourner votre analyse au format JSON, en respectant la structure suivante :
Veuillez fournir l'analyse dans le format JSON suivant, en vous assurant que tous les champs sont présents et remplis :
```json
{{
"score_pertinence": int,
"points_forts": ["string", "string", ...],
"ameliorations_cv": ["string", "string", ...],
"phrase_accroche_lm": "string",
"mots_cles_offre": ["string", "string", ...]
"match_score": "Score de correspondance global (sur 100).",
"correspondances": [
{{
"categorie": "Catégorie de correspondance (ex: 'Compétences techniques', 'Expérience', 'Qualités personnelles', 'Mots-clés')",
"elements": ["Liste des éléments correspondants trouvés dans l'offre et le CV."]
}}
],
"lacunes": [
{{
"categorie": "Catégorie de lacune (ex: 'Compétences manquantes', 'Expérience insuffisante', 'Mots-clés absents')",
"elements": ["Liste des éléments de l'offre d'emploi qui ne sont pas (ou peu) présents dans le CV."]
}}
],
"suggestions_cv": [
"Suggestions spécifiques pour améliorer le CV afin de mieux correspondre à l'offre (ex: 'Ajouter des détails sur...', 'Mettre en avant l'expérience en...', 'Inclure le mot-clé...')."
],
"qualites_perso_identifiees": ["Liste des qualités personnelles déduites du CV."],
"mots_cles_pertinents_offre": ["Liste des mots-clés importants identifiés dans l'offre d'emploi."],
"metiers_rome_suggeres_offre": [],
"competences_rome_suggeres_offre": [],
"analyse_detaillee": "Une analyse narrative plus approfondie des points forts et faibles du CV par rapport à l'offre, et un résumé général."
}}
```
Assurez-vous que la réponse est un JSON valide et complet. Ne pas inclure de texte explicatif avant ou après le bloc JSON.
"""
logger.info(f"Envoi du prompt au LLM ({self.provider}): \n {prompt[:200]}...") # Log des 200 premiers caractères du prompt
response_content = ""
if self.provider == "gemini":
try:
# MODIFIÉ ICI: Utilisation d'une lambda pour envelopper l'appel à generate_content
# avec tous ses arguments, pour que run_sync reçoive une fonction sans arguments supplémentaires
response = await anyio.to_thread.run_sync(
lambda: self.client.models.generate_content(
model=self.model_name,
contents=[{"role": "user", "parts": [{"text": prompt}]}],
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}")
if self.provider == "gemini":
response_content = await self._call_gemini_api(prompt)
elif self.provider == "mistral":
response_content = await self._call_mistral_api(prompt)
else:
raise ValueError("Fournisseur LLM non supporté.")
except Exception as e:
logger.error(f"Échec de l'appel au service LLM: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="La réponse de l'IA n'était pas au format JSON attendu."
detail=f"Erreur lors de l'appel au service LLM: {e}"
)
# Instanciation unique du service AI
try:
# Gemini renvoie parfois du Markdown, donc on extrait le JSON
if self.provider == "gemini" and "```json" in response_content:
json_start = response_content.find("```json") + len("```json")
json_end = response_content.find("```", json_start)
if json_end != -1:
json_str = response_content[json_start:json_end].strip()
else:
json_str = response_content[json_start:].strip()
else:
json_str = response_content.strip()
analysis_result = json.loads(json_str)
logger.info("Réponse JSON du LLM parsée avec succès.")
except json.JSONDecodeError as e:
logger.error(f"Erreur de parsing JSON depuis la réponse du LLM: {e}. Réponse brute: {response_content}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur de format de réponse du LLM. Impossible de parser le JSON."
)
except Exception as e:
logger.error(f"Erreur inattendue lors du traitement de la réponse LLM: {e}. Réponse brute: {response_content}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur inattendue lors du traitement de la réponse LLM: {e}"
)
# Intégration du service Romeo
try:
logger.info("Début de l'intégration avec le service Romeo...")
# S'assurer que les textes ne sont pas None avant de les passer à Romeo
job_offer_text_for_romeo = job_offer_text if job_offer_text is not None else ""
cv_text_for_romeo = cv_text if cv_text is not None else ""
# Appels aux services Romeo
# romeo_metiers_predictions = await romeo_service.predict_metiers(job_offer_text_for_romeo)
# romeo_competences_predictions = await romeo_service.predict_competences(job_offer_text_for_romeo)
# NOTE: Les appels Romeo sont mis en commentaire car vous pourriez vouloir les activer sélectivement
# ou les décommenter une fois que la base d'analyse LLM est stable.
# Si vous utilisez romeo_service, assurez-vous qu'il est correctement initialisé et accessible.
# Exemple de comment les utiliser si activé:
# Extraction des codes ROME (par exemple, 'D1101') des prédictions métiers
# et des codes de compétences ROME (par exemple, 'G1601') des prédictions de compétences
# predicted_rome_metiers = [
# m["codeRome"] for m in romeo_metiers_predictions if "codeRome" in m
# ] if romeo_metiers_predictions else []
# predicted_rome_competences = [
# c["codeAppellation"] for c in romeo_competences_predictions if "codeAppellation" in c
# ] if romeo_competences_predictions else []
# Utiliser ces prédictions de Romeo pour mettre à jour le résultat de l'analyse
# analysis_result["metiers_rome_suggeres_offre"] = list(set(predicted_rome_metiers)) # Utilise set pour éviter les doublons
# analysis_result["competences_rome_suggeres_offre"] = list(set(predicted_rome_competences)) # Utilise set pour éviter les doublons
logger.info("Intégration Romeo terminée avec succès (ou ignorée si en commentaire).")
except Exception as e:
logger.error(f"Erreur lors de l'intégration avec le service Romeo: {e}")
# Ne pas relancer une HTTPException ici si l'intégration Romeo est optionnelle ou en cours de développement,
# car cela masquerait l'analyse LLM. Vous pouvez choisir de logguer et continuer, ou de relancer si c'est critique.
# Pour l'instant, on se contente de logguer l'erreur.
pass
return analysis_result
# Instanciation unique du service AI...
ai_service = AIService()

View file

@ -2,7 +2,7 @@
import httpx
import logging
from core.config import settings
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class FranceTravailAuthService:
@ -16,12 +16,12 @@ class FranceTravailAuthService:
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():
if self._token_cache and self._token_cache.get("expires_at", 0) > datetime.now():
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
token_url = settings.FRANCE_TRAVAIL_TOKEN_URL # C'est la ligne modifiée
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
@ -34,35 +34,36 @@ class FranceTravailAuthService:
}
headers = {
"Content-Type": "application/x-www-form-urlencoded" # C'est très important !
"Content-Type": "application/x-www-form-urlencoded"
}
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient() as client:
try:
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
# DÉBUT DE LA CORRECTION : Ces lignes sont maintenant correctement indentées dans le bloc try
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.")
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}")
# Mettre à jour le cache
self._token_cache = {
"access_token": access_token,
"expires_at": datetime.now() + timedelta(seconds=expires_in - 60) # 60 secondes de marge de sécurité
}
logger.info("Nouveau token France Travail obtenu et mis en cache.")
return access_token
# FIN DE LA CORRECTION
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

@ -1,197 +1,225 @@
# backend/services/france_travail_offer_service.py
import httpx
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any, Union
import time
from typing import Optional, Dict, Any, List, Tuple
import asyncio
from core.config import settings
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre, TypeContrat
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail
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
self.client = httpx.AsyncClient(base_url=settings.FRANCE_TRAVAIL_API_BASE_URL)
self.auth_client = httpx.AsyncClient(base_url=settings.FRANCE_TRAVAIL_TOKEN_URL.split('?')[0])
self.token_info = {"token": None, "expires_at": 0}
self.geo_api_client = httpx.AsyncClient() # Client for geo.api.gouv.fr
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
if self.token_info["token"] and self.token_info["expires_at"] > time.time() + 60: # Refresh 1 min before expiry
logger.info("Utilisation du token France Travail depuis le cache.")
return self.token_info["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
}
logger.info("Obtention d'un nouveau token France Travail...")
try:
token_url_with_realm = settings.FRANCE_TRAVAIL_TOKEN_URL
response = await self.auth_client.post(
token_url_with_realm,
data={
"grant_type": "client_credentials",
"client_id": settings.FRANCE_TRAVAIL_CLIENT_ID,
"client_secret": settings.FRANCE_TRAVAIL_CLIENT_SECRET,
"scope": "o2dsoffre api_offresdemploiv2"
}
)
response.raise_for_status()
token_data = response.json()
self.token_info["token"] = token_data["access_token"]
self.token_info["expires_at"] = time.time() + token_data["expires_in"]
logger.info("Nouveau token France Travail obtenu et mis en cache.")
return self.token_info["token"]
except httpx.HTTPStatusError as e:
logger.error(f"Échec 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 httpx.RequestError as e:
logger.error(f"Erreur réseau lors de l'obtention du token France Travail: {e}")
raise RuntimeError(f"Erreur réseau lors de l'obtention du token France Travail: {e}")
async with httpx.AsyncClient() as client:
try:
response = await client.post(self.token_url, headers=headers, data=data)
async def get_insee_and_postal_code_for_commune(self, commune_name: str) -> Optional[Tuple[str, str, float, float]]:
logger.info(f"Début de la recherche Geo API pour: '{commune_name}'")
try:
# First, try exact match by name
geo_url_by_name = f"https://geo.api.gouv.fr/communes?nom={commune_name}&fields=codesPostaux,code,nom,centre&format=json&limit=5"
logger.info(f"Recherche par nom via API Geo.gouv.fr: {geo_url_by_name}")
response = await self.geo_api_client.get(geo_url_by_name)
response.raise_for_status()
communes = response.json()
for commune in communes:
# Prioritize exact name match
if commune['nom'].lower() == commune_name.lower():
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Correspondance exacte par nom trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
# If no exact name match, try the first result if available
if communes:
commune = communes[0]
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Aucune correspondance exacte par nom, première commune trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
# If not found by name, try by postal code if commune_name looks like a postal code
if commune_name.isdigit() and len(commune_name) == 5:
geo_url_by_cp = f"https://geo.api.gouv.fr/communes?codePostal={commune_name}&fields=codesPostaux,code,nom,centre&format=json&limit=1"
logger.info(f"Recherche par code postal via API Geo.gouv.fr: {geo_url_by_cp}")
response = await self.geo_api_client.get(geo_url_by_cp)
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)
communes_by_cp = response.json()
if communes_by_cp:
commune = communes_by_cp[0]
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Correspondance par code postal trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour code postal '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
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}")
logger.warning(f"Aucune correspondance trouvée pour la commune/code postal: '{commune_name}' dans l'API Geo.gouv.fr.")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'appel à l'API Geo.gouv.fr pour '{commune_name}': {e.response.status_code} - {e.response.text}")
return None
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de l'appel à l'API Geo.gouv.fr pour '{commune_name}': {e}")
return None
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.
"""
async def search_offers(
self,
motsCles: Optional[str] = None,
commune: Optional[str] = None, # Reste le nom de la commune
codePostal: Optional[str] = None,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
distance: Optional[int] = None,
# codeDepartement: Optional[str] = None, # Ce paramètre sera maintenant dérivé en interne
range_start: int = 0,
range_end: int = 14,
typeContrat: Optional[List[str]] = None,
experience: Optional[List[str]] = None
) -> FranceTravailSearchResponse:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"q": commune_name
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
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,
params: Dict[str, Any] = {
"range": f"{range_start}-{range_end}"
}
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()
params["typeContrat"] = ','.join(typeContrat)
if experience:
params["experience"] = ','.join(experience)
if codePostal:
# Logique de localisation améliorée
# insee_code = None # Non utilisé directement comme paramètre pour l'API France Travail
# postal_code_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# latitude_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# longitude_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# Le codeDepartement sera déterminé ici si une commune est fournie
derived_departement_code = None
if commune:
logger.info(f"Traitement de la commune spécifiée: '{commune}' pour dériver le département.")
geo_data = await self.get_insee_and_postal_code_for_commune(commune)
if geo_data:
insee_code, postal_code_from_geo, latitude_from_geo, longitude_from_geo = geo_data
# Dériver le code départemental du code postal ou INSEE
if postal_code_from_geo and len(postal_code_from_geo) >= 2:
derived_departement_code = postal_code_from_geo[:2]
# Cas spécifiques pour la Corse
if postal_code_from_geo.startswith('2A'):
derived_departement_code = '2A'
elif postal_code_from_geo.startswith('2B'):
derived_departement_code = '2B'
logger.info(f"Département dérivé de '{commune}': {derived_departement_code}")
# Si un département est dérivé, l'utiliser prioritairement
if derived_departement_code:
params["departement"] = derived_departement_code
logger.info(f"Paramètre 'departement' utilisé (dérivé de la commune): {derived_departement_code}")
elif latitude_from_geo and longitude_from_geo and distance is not None:
# Fallback sur latitude/longitude si la dérivation du département échoue
params["latitude"] = latitude_from_geo
params["longitude"] = longitude_from_geo
params["distance"] = distance
logger.info(f"Paramètres de localisation utilisés (dérivés de la commune): Latitude/Longitude et Distance: Lat={latitude_from_geo}, Long={longitude_from_geo}, Dist={distance}")
else:
logger.warning(f"Impossible de dériver le département ou d'obtenir des coordonnées valides pour la commune '{commune}'. Recherche sans localisation précise.")
else:
logger.warning(f"Impossible d'obtenir les données géographiques pour la commune '{commune}'. Recherche sans localisation précise.")
elif codePostal:
params["codePostal"] = codePostal
if distance is not None:
params["distance"] = distance
else:
params["distance"] = 10
elif commune:
params["commune"] = commune
logger.info(f"Paramètres de localisation utilisés: Code Postal={codePostal}, Distance={distance}")
elif latitude is not None and longitude is not None:
params["latitude"] = latitude
params["longitude"] = longitude
if distance is not None:
params["distance"] = distance
logger.info(f"Paramètres de localisation utilisés: Latitude/Longitude: Lat={latitude}, Long={longitude}, Dist={distance}")
else:
logger.warning("Aucun paramètre de localisation valide (commune, code postal, lat/long) n'a été spécifié. La recherche sera nationale.")
logger.info(f"Appel à l'API France Travail pour search_offers avec paramètres FINAUX: {params}")
try:
response = await self.client.get("/v2/offres/search", headers=headers, params=params)
response.raise_for_status()
logger.info(f"Réponse brute de l'API France Travail (search_offers): {response.json()}")
return FranceTravailSearchResponse(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Échec de la recherche d'offres France Travail: {e.response.status_code} - {e.response.text}")
if e.response.status_code == 400 and "incorrect value" in e.response.text.lower() and "commune" in e.response.text.lower():
raise RuntimeError(f"L'API France Travail a renvoyé une erreur 400: La valeur du paramètre 'commune' est incorrecte. Veuillez vérifier le code INSEE ou envisager une recherche par département ou latitude/longitude.")
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}")
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de la recherche d'offres France Travail: {e}")
raise RuntimeError(f"Erreur réseau 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}"
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
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}")
logger.info(f"Appel à l'API France Travail pour get_offer_details avec id: {offer_id}")
try:
response = await self.client.get(f"/v2/offres/{offer_id}", headers=headers)
response.raise_for_status()
logger.info(f"Réponse brute de l'API France Travail (get_offer_details): {response.json()}")
return OffreDetail(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Échec 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 httpx.RequestError as e:
logger.error(f"Erreur réseau lors de la récupération des détails de l'offre {offer_id}: {e}")
raise RuntimeError(f"Erreur réseau lors de la récupération des détails de l'offre {offer_id}: {e}")
france_travail_offer_service = FranceTravailOfferService()

View file

@ -1,81 +1,62 @@
import httpx
import logging
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any
from core.config import settings
from services.oauth_service import OAuthService # Assurez-vous que ce service existe
from services.france_travail_auth_service import france_travail_auth_service
logger = logging.getLogger(__name__)
class RomeoService:
def __init__(self):
self.base_url = settings.FRANCE_TRAVAIL_ROMEO_API_URL # URL de base de l'API Romeo
self.scope = "api_romeov2" # Scope spécifique pour Romeo
self.oauth_service = OAuthService(settings.FRANCE_TRAVAIL_OAUTH_URL, settings.FRANCE_TRAVAIL_CLIENT_ID, settings.FRANCE_TRAVAIL_CLIENT_SECRET)
self.client = httpx.AsyncClient()
# CORRIGÉ ICI: Utilise 'FRANCE_TRAVAIL_ROMEO_API_URL' comme suggéré par l'erreur
self.base_url = settings.FRANCE_TRAVAIL_ROMEO_API_URL
self._http_client = httpx.AsyncClient()
logger.info(f"RomeoService initialized with base_url: {self.base_url}")
async def _get_access_token(self) -> str:
"""Récupère le token d'accès spécifique à l'API Romeo."""
try:
token_response = await self.oauth_service.get_access_token(self.scope)
return token_response.access_token
except Exception as e:
logger.error(f"Erreur lors de la récupération du token d'accès pour Romeo: {e}")
raise RuntimeError(f"Impossible de récupérer le token d'accès pour Romeo: {e}")
async def _call_api(self, endpoint: str, text: str) -> Optional[Dict[str, Any]]:
async def _call_api(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Appelle un endpoint de l'API Romeo avec le texte donné.
Gère l'authentification et les erreurs de base.
Appel générique à une endpoint de l'API Romeo.
Récupère le jeton d'accès via le service d'authentification.
"""
token = await self._get_access_token()
access_token = await france_travail_auth_service.get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8"
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
# Les APIs Romeo attendent le texte dans un champ 'texte' de l'objet JSON
data = {"texte": text}
url = f"{self.base_url}{endpoint}"
logger.info(f"Calling Romeo API: {url} with text length {len(text)}")
logger.info(f"Appel API Romeo: {url} avec données: {data.keys()}")
try:
response = await self.client.post(url, headers=headers, json=data, timeout=30.0)
response.raise_for_status() # Lève une exception pour les codes d'erreur HTTP (4xx ou 5xx)
response = await self._http_client.post(url, json=data, headers=headers)
response.raise_for_status() # Lève une exception pour les codes d'état HTTP 4xx/5xx
logger.info(f"Réponse API Romeo reçue (status: {response.status_code}).")
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'appel à Romeo {endpoint}: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Erreur de l'API Romeo: {e.response.text}")
except httpx.RequestError as e:
logger.error(f"Erreur réseau ou de requête lors de l'appel à Romeo {endpoint}: {e}")
raise RuntimeError(f"Erreur de communication avec l'API Romeo: {e}")
logger.error(f"Erreur HTTP lors de l'appel à l'API Romeo: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Erreur lors de l'appel à l'API Romeo: {e.response.text}")
except Exception as e:
logger.error(f"Une erreur inattendue est survenue lors de l'appel à Romeo {endpoint}: {e}")
raise RuntimeError(f"Erreur inattendue avec l'API Romeo: {e}")
logger.error(f"Erreur inattendue lors de l'appel à l'API Romeo: {e}")
raise RuntimeError(f"Erreur inattendue lors de l'appel à l'API Romeo: {e}")
async def predict_metiers(self, text: str) -> List[Dict[str, Any]]:
"""
Prédit les métiers ROME à partir d'un texte donné.
Retourne une liste de dictionnaires avec les détails des prédictions métiers.
"""
if not text:
return [] # Retourne une liste vide si le texte est vide
response_data = await self._call_api("/predictionMetiers", text)
# Romeo renvoie souvent une liste directe de prédictions si successful
return response_data if response_data is not None else []
endpoint = "/predire/metiers"
data = {"texte": text}
response_data = await self._call_api(endpoint, data)
return response_data.get("predictions", [])
async def predict_competences(self, text: str) -> List[Dict[str, Any]]:
"""
Prédit les compétences ROME à partir d'un texte donné.
Retourne une liste de dictionnaires avec les détails des prédictions de compétences.
"""
if not text:
return [] # Retourne une liste vide si le texte est vide
response_data = await self._call_api("/predictionCompetences", text)
# Romeo renvoie souvent une liste directe de prédictions si successful
return response_data if response_data is not None else []
endpoint = "/predire/competences"
data = {"texte": text}
response_data = await self._call_api(endpoint, data)
return response_data.get("predictions", [])
# Instanciation unique du service Romeo
romeo_service = RomeoService()