Vortex-Flux / src /modules /jasmine_widget.py
jaimalklyde's picture
JASMINE chat sees page controls and answers what-if questions
4edda61
Raw History Blame Contribute Delete
7.84 kB
"""
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()