"""Application Gemini « directe » : prompt géré dans Langfuse, recherche de fiches, appel au modèle.""" from __future__ import annotations import logging from dataclasses import dataclass from functools import lru_cache from google import genai from google.genai import types from langfuse import Langfuse, observe, propagate_attributes from .config import SETTINGS from .connaissances import rechercher from .observabilite import usage_gemini log = logging.getLogger(__name__) NOM_PROMPT = "conseiller-reponse" # Copie de secours : utilisée si Langfuse est injoignable au premier appel (voir leçon 4). PROMPT_SECOURS = [ {"role": "system", "content": "Tu assistes les conseillers de la Mutuelle Arvea. Réponds en français, " "en trois phrases au plus, uniquement à partir des fiches fournies. Si les fiches ne couvrent pas " "la question, dis-le et propose d'ouvrir un ticket."}, {"role": "user", "content": "Fiches :\n{{fiches}}\n\nQuestion du conseiller : {{question}}"}, ] @dataclass class Reponse: texte: str references: list[str] version_prompt: int | None @lru_cache(maxsize=1) def client_gemini() -> genai.Client: """Un seul client pour tout le processus : un client temporaire non conservé se ferme.""" retry = types.HttpRetryOptions(attempts=4, initial_delay=2.0, http_status_codes=[429, 500, 503]) return genai.Client(http_options=types.HttpOptions(retry_options=retry)) def separer(messages: list[dict]) -> tuple[str, str]: """Convertit un prompt « chat » (system + user) au format Gemini : instruction système + contenu.""" systeme = "\n".join(m["content"] for m in messages if m["role"] == "system") utilisateur = "\n".join(m["content"] for m in messages if m["role"] == "user") return systeme, utilisateur def charger_prompt(langfuse: Langfuse, label: str): # cache_ttl_seconds : le SDK garde le prompt 60 s en mémoire et le rafraîchit en arrière-plan. return langfuse.get_prompt(NOM_PROMPT, type="chat", label=label, fallback=PROMPT_SECOURS, cache_ttl_seconds=60) # capture_input=False : par défaut, @observe sérialise TOUS les arguments dans la trace, # y compris l'objet client Langfuse... et sa clé secrète (constaté, voir leçon 3). @observe(name="repondre-conseiller", capture_input=False) def repondre(langfuse: Langfuse, question: str, *, user_id: str, session_id: str, label: str | None = None, client: genai.Client | None = None) -> Reponse: langfuse.update_current_span(input={"question": question}) prompt = charger_prompt(langfuse, label or SETTINGS.prompt_label) fiches = rechercher(question) texte_fiches = "\n".join(f"[{ref}] {txt}" for ref, txt in fiches) or "(aucune fiche trouvée)" systeme, contenu = separer(prompt.compile(fiches=texte_fiches, question=question)) config = prompt.config or {} parametres = {"temperature": config.get("temperature", 0.2), "max_output_tokens": config.get("max_output_tokens", 1024)} # propagate_attributes : utilisateur, session et nom de trace sur toutes les observations du bloc. with propagate_attributes(user_id=user_id, session_id=session_id, tags=["appli-gemini"], trace_name="repondre-conseiller"): with langfuse.start_as_current_observation( as_type="generation", name="gemini", model=SETTINGS.model, prompt=prompt, input={"system": systeme, "user": contenu}, model_parameters=parametres, ) as generation: resultat = (client or client_gemini()).models.generate_content( model=SETTINGS.model, contents=contenu, config=types.GenerateContentConfig(system_instruction=systeme, **parametres), ) fin = resultat.candidates[0].finish_reason if resultat.candidates else None generation.update( output=resultat.text, usage_details=usage_gemini(resultat.usage_metadata), # Le modèle servi peut différer du modèle demandé : on garde la trace sans changer le prix. metadata={"model_version": resultat.model_version, "finish_reason": str(fin)}, ) if fin == types.FinishReason.MAX_TOKENS: log.warning("réponse tronquée : max_output_tokens atteint (réflexion comprise)") generation.update(level="WARNING", status_message="réponse tronquée (MAX_TOKENS)") texte = (resultat.text or "").strip() log.info("réponse générée (prompt v%s, %d fiche(s))", prompt.version, len(fiches)) return Reponse(texte=texte, references=[r for r, _ in fiches], version_prompt=None if prompt.is_fallback else prompt.version)