""" WIDGET JASMINE LEGER PAR MODULE ================================= Assistant IA reutilisable, integrable sur n'importe quelle page. Contrairement a l'agent JASMINE global (src/modules/jasmine_agent.py, page Ontology), ce widget n'utilise PAS le moteur RDF/SPARQL (trop couteux a construire pour un usage ponctuel par page) : il s'appuie uniquement sur le client LLM centralise (src/core/llm_client.py) et les outils de calcul partages (src/agent/calc_tools.py), avec pour seul contexte les donnees deja chargees par la page appelante. """ import json import re import streamlit as st from core.llm_client import call_llm, LLMUnavailableError from core import exercice as ex from core import graphiques_contexte from agent.calc_tools import TOOL_FUNCTIONS, TOOL_DESCRIPTIONS _ALLOWED_ARGS = {"n_months", "horizon_weeks", "n_runs", "dossier"} def _build_system_prompt(scope_label, context_data, actions, allowed_tool_names): tools_txt = "\n".join(f"- {name} : {desc}" for name, desc in TOOL_DESCRIPTIONS.items() if name in allowed_tool_names) if actions: tools_txt += "\n" + "\n".join(f"- {name} : {meta['description']}" for name, meta in actions.items()) context_json = json.dumps(context_data, ensure_ascii=False, indent=2, default=str) actions_note = ( "Certains outils ci-dessus AGISSENT reellement sur cette page (ils relancent un " "calcul et mettent a jour ce que l'utilisateur voit a l'ecran), en plus des outils " "de calcul en lecture seule. Utilise-les si l'utilisateur demande explicitement " "de faire/relancer/mettre a jour quelque chose." if actions else "" ) return f"""ROLE: Tu es JASMINE, l'assistant IA de VORTEX, limité aux données de cette page. PORTÉE: {scope_label}. Tu ne connais QUE les données ci-dessous — pas d'accès à l'ontologie globale, ni aux données d'autres clients. {ex.consigne_ia()} DONNÉES DE LA PAGE : {context_json} OUTILS DISPONIBLES (optionnels, à utiliser seulement si nécessaire) : {tools_txt} {actions_note} Réponds aux questions en te basant uniquement sur les données ci-dessus. Sois direct et factuel, sans familiarité excessive — ce n'est pas une conversation avec un enfant. CONTRÔLES ET HYPOTHÈSES : les lignes "controle" de texte_affiche_a_l_ecran sont les cases, curseurs, listes et boutons de la page, avec leur état actuel. Pour une question « et si je coche / change / accepte X ? », explique ce que ce contrôle modifie d'après guide_page, les définitions et les formules, et chiffre l'effet avec les valeurs fournies (scénarios déjà calculés inclus). Ne réponds jamais seulement « je n'ai pas l'information » quand le guide ou les valeurs permettent de raisonner ; si une donnée précise manque, nomme-la. FORMAT JSON STRICT : {{ "reponse": "ta réponse ici si aucun outil n'est nécessaire, sinon chaîne vide", "tool": "nom_outil ou none", "args": {{}} }} """ def render_scoped_widget(client, sheet_name, scope_label: str, context_data: dict, key_prefix: str, actions: dict = None, allowed_tools: list = None): """Affiche un expander de chat JASMINE scopé aux données de la page appelante. actions (optionnel) : {"nom_action": {"fn": callable(**args) -> dict, "description": str}} — des outils propres à la page appelante qui AGISSENT réellement (relancent un calcul, mettent à jour st.session_state) plutôt que de se contenter de calculer une réponse. La fonction est appelée directement (thread principal Streamlit) et son résultat doit être un dict JSON-sérialisable ; le widget déclenche un st.rerun() après coup pour que la page reflète le changement. allowed_tools (optionnel) : liste blanche des noms d'outils de calc_tools.py autorisés sur cette page. None (défaut) = tous les outils, pour les pages internes authentifiées. Une page publique/non authentifiée doit passer une liste explicite (potentiellement vide []) — le LLM ne doit jamais décider seul de l'accès a des donnees hors du scope de cette page ; le filtrage se fait cote Python, pas seulement via le system prompt.""" actions = actions or {} # Graphiques affiches sur cette page (captes par core/graphiques_contexte.py) : le chat les lit. graphiques = graphiques_contexte.pour_page(st.session_state.get("_audit_menu", "")) if graphiques: context_data = {**context_data, "graphiques_affiches": graphiques} # Texte affiche sur la page (recommandations, valeurs, alertes, tableaux) : ce que l'utilisateur lit. affichage = graphiques_contexte.texte_page(st.session_state.get("_audit_menu", "")) if affichage: context_data = {**context_data, "texte_affiche_a_l_ecran": affichage} allowed_tool_names = set(TOOL_FUNCTIONS.keys()) if allowed_tools is None else set(allowed_tools) & set(TOOL_FUNCTIONS.keys()) history_key = f"{key_prefix}_jasmine_chat" if history_key not in st.session_state: st.session_state[history_key] = [] with st.expander("Demander à JASMINE (assistant limité aux données de cette page)", expanded=False): for msg in st.session_state[history_key]: with st.chat_message(msg["role"]): st.code(msg["content"], language=None, wrap_lines=True) prompt = st.chat_input("Poser une question...", key=f"{key_prefix}_jasmine_input") if not prompt: return st.session_state[history_key].append({"role": "user", "content": prompt}) system_prompt = _build_system_prompt(scope_label, context_data, actions, allowed_tool_names) messages = [{"role": "system", "content": system_prompt}] messages.extend(st.session_state[history_key][-6:]) try: with st.spinner("JASMINE réfléchit..."): texte = call_llm(messages, json_mode=True, temperature=0.2) clean = re.sub(r'```json\s*|\s*```', '', texte.strip()) action = json.loads(clean) except (LLMUnavailableError, json.JSONDecodeError) as e: st.session_state[history_key].append({"role": "assistant", "content": f"[ATTENTION] Assistant indisponible : {e}"}) st.rerun() return tool_name = action.get("tool", "none") # Filtrage impose cote Python : meme si le LLM a ete manipule (injection de # prompt) pour demander un outil hors liste blanche, l'appel n'a jamais lieu. if tool_name in TOOL_FUNCTIONS and tool_name not in allowed_tool_names: reponse = "Cet outil n'est pas disponible sur cette page." elif tool_name in TOOL_FUNCTIONS: calc_args = {k: v for k, v in (action.get("args") or {}).items() if k in _ALLOWED_ARGS} try: with st.spinner(f"Calcul en cours ({tool_name})..."): result = TOOL_FUNCTIONS[tool_name](client, sheet_name, **calc_args) reponse = f"{action.get('reponse', '').strip()}\n\n```json\n{json.dumps(result, ensure_ascii=False, indent=2)}\n```".strip() except Exception as e: reponse = f"[ATTENTION] Erreur lors du calcul ({tool_name}) : {e}" elif tool_name in actions: try: with st.spinner(f"Exécution en cours ({tool_name})..."): result = actions[tool_name]["fn"](**(action.get("args") or {})) reponse = f"{action.get('reponse', '').strip()}\n\n[OK] {result.get('message', 'Action exécutée.')}".strip() except Exception as e: reponse = f"[ATTENTION] Erreur lors de l'exécution de l'action ({tool_name}) : {e}" else: reponse = action.get("reponse") or "Je n'ai pas compris la question." st.session_state[history_key].append({"role": "assistant", "content": reponse}) st.rerun()