| """Gradio web demo of JDMAgent for Hugging Face Spaces. |
| |
| Three tabs: |
| 1. Explorer JDM (no LLM, instant) |
| 2. Fact-checker (deterministic on direct claims, BYOK for text extraction) |
| 3. Agent JDM (BYOK conversational) |
| |
| The user brings their own Anthropic API key for the LLM-powered tabs. |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import sys |
| from pathlib import Path |
|
|
| |
| _root = Path(__file__).parent |
| sys.path.insert(0, str(_root / "src")) |
|
|
| |
| |
| |
| |
| try: |
| from dotenv import load_dotenv as _load_dotenv |
| _load_dotenv(override=False) |
| except ImportError: |
| pass |
|
|
| |
| |
| |
| |
| |
| os.environ.setdefault("JDM_CACHE_DIR", "/tmp/jdm_cache") |
|
|
| |
| |
| |
| |
| _GRADIO_TEMP = "/tmp/jdm_gradio_cache" |
| os.makedirs(_GRADIO_TEMP, exist_ok=True) |
| os.environ.setdefault("GRADIO_TEMP_DIR", _GRADIO_TEMP) |
|
|
| import gradio as gr |
| import pandas as pd |
|
|
| from jdm_agent.client import JDMClient |
| from jdm_agent.factcheck import Claim, verify_claim |
| from jdm_agent.factcheck.models import Status |
| from jdm_agent.viz import ( |
| DEFAULT_DEPTH2_RELATIONS, |
| DEFAULT_DEPTH3_RELATIONS, |
| DEFAULT_DEPTH4_RELATIONS, |
| DEFAULT_RELATIONS, |
| build_subgraph, |
| ) |
|
|
|
|
| |
| _client: JDMClient | None = None |
|
|
|
|
| def get_client() -> JDMClient: |
| global _client |
| if _client is None: |
| _client = JDMClient() |
| return _client |
|
|
|
|
| |
|
|
| |
| EXPLORE_RELATIONS = { |
| "Synonymes (r_syn)": "r_syn", |
| "Antonymes (r_anto)": "r_anto", |
| "Hyperonymes — 'est un' (r_isa)": "r_isa", |
| "Hyponymes — 'exemples de' (r_hypo)": "r_hypo", |
| "Parties / composants (r_has_part)": "r_has_part", |
| "Caractéristiques (r_carac)": "r_carac", |
| "Couleurs (r_has_color)": "r_has_color", |
| "Lieux typiques (r_lieu)": "r_lieu", |
| "Agents typiques (r_agent) — verbe": "r_agent", |
| "Patients typiques (r_patient) — verbe": "r_patient", |
| "Instruments (r_instr) — verbe": "r_instr", |
| "Rôle télique — à quoi sert (r_telic_role)": "r_telic_role", |
| "Causes (r_has_causatif)": "r_has_causatif", |
| "Conséquences (r_has_conseq)": "r_has_conseq", |
| "But (r_but)": "r_but", |
| "Manière (r_manner) — verbe / processus": "r_manner", |
| } |
|
|
|
|
| def _resolve_and_check(client, term: str) -> tuple[str, str]: |
| """Résout une forme molle de raffinement et vérifie l'existence du terme. |
| |
| Renvoie (terme_résolu, message_erreur). `message_erreur` est non vide si |
| le terme est absent de JDM — l'UI affiche alors un message clair plutôt |
| que l'erreur serveur 500 brute renvoyée par l'API. |
| """ |
| raw = (term or "").strip() |
| if not raw: |
| return raw, "Renseigne un terme." |
| resolved = client.resolve_term(raw) |
| if not client.term_exists(resolved): |
| return resolved, f"« {raw} » n'est pas connu de JeuxDeMots." |
| return resolved, "" |
|
|
|
|
| def explore(term: str, relation_label: str, min_weight: float, |
| limit: int, with_annotations: bool) -> tuple[pd.DataFrame, str]: |
| c = get_client() |
| term, err = _resolve_and_check(c, term) |
| if err: |
| return pd.DataFrame(), err |
| rel_name = EXPLORE_RELATIONS[relation_label] |
| rid = c.relation_type_id(rel_name) |
| if rid is None: |
| return pd.DataFrame(), f"Relation inconnue : {rel_name!r}" |
|
|
| try: |
| res = c.relations_from(term, types_ids=[rid], |
| min_weight=float(min_weight), limit=int(limit)) |
| except Exception as e: |
| return pd.DataFrame(), f"Erreur API JDM : {e}" |
|
|
| idx = res.node_index() |
| rows: list[dict] = [] |
| for r in sorted(res.relations, key=lambda x: -x.w): |
| node = idx.get(r.node2) |
| if node is None: |
| try: |
| node = c.node_by_id(r.node2) |
| except Exception: |
| continue |
| dec = c.decode_node_name(node.name, local_nodes=idx) |
| |
| |
| |
| annot_str = "" |
| if with_annotations: |
| try: |
| anns = c.get_annotations_for_triplet(r.id) |
| if anns: |
| annot_str = " ; ".join( |
| f"{a.value} (w={int(round(a.w))})" for a in anns |
| ) |
| except Exception: |
| annot_str = "" |
| rows.append({ |
| "source": term, |
| "relation": rel_name, |
| "target": dec["decoded"], |
| "w": round(r.w, 1), |
| "annotations": annot_str, |
| "target_id (si raffinement)": node.name if dec["is_refinement"] else "", |
| }) |
| if not rows: |
| msg = (f"Aucun triplet `{term} | {rel_name} | ?` (w ≥ {min_weight:.0f}). " |
| f"Essaie un seuil plus bas, ou un autre terme.") |
| return pd.DataFrame(), msg |
| df = pd.DataFrame(rows) |
| return df, f"{len(rows)} triplet(s) trouvé(s)." |
|
|
|
|
| def disambiguate_term(term: str) -> tuple[pd.DataFrame, str]: |
| if not term.strip(): |
| return pd.DataFrame(), "Renseigne un terme polysémique (avocat, souris, police, ...)." |
| c = get_client() |
| if not c.term_exists(term.strip()): |
| return pd.DataFrame(), f"« {term.strip()} » n'est pas connu de JeuxDeMots." |
| try: |
| senses = c.refinements_decoded(term.strip()) |
| except Exception as e: |
| return pd.DataFrame(), f"Erreur : {e}" |
| senses.sort(key=lambda s: -s.weight) |
| if not senses: |
| return pd.DataFrame(), f"Aucun sens raffiné trouvé pour {term!r} (terme probablement monosémique)." |
| rows = [ |
| {"sens (décodé)": s.decoded, "poids": round(s.weight, 1), "id JDM": s.name} |
| for s in senses[:30] |
| ] |
| return pd.DataFrame(rows), f"{len(rows)} sens trouvés." |
|
|
|
|
| |
|
|
| CLAIM_RELATIONS = [ |
| "r_isa", "r_hypo", "r_carac", "r_has_color", "r_has_part", |
| "r_agent", "r_patient", "r_instr", "r_lieu", |
| "r_has_causatif", "r_has_conseq", "r_but", "r_telic_role", |
| ] |
|
|
|
|
| |
| EFFORT_CHOICES = { |
| "0 — Contenance (JDM contient-il ?)": 0, |
| "1 — + inférence (noyau)": 1, |
| "2 — + inférence (complète)": 2, |
| } |
|
|
|
|
| def factcheck_one(subject: str, relation: str, object_: str, |
| effort_label: str, bypass: bool) -> tuple[str, str]: |
| if not (subject.strip() and object_.strip()): |
| return "—", "Renseigne un sujet et un objet." |
| effort = EFFORT_CHOICES.get(effort_label, 0) |
| c = get_client() |
| |
| subj, err_s = _resolve_and_check(c, subject) |
| if err_s: |
| return "—", err_s |
| obj, err_o = _resolve_and_check(c, object_) |
| if err_o: |
| return "—", err_o |
| claim = Claim(text=f"{subj} | {relation} | {obj}", |
| subject=subj, relation=relation, object=obj) |
| try: |
| v = verify_claim(c, claim, effort=effort, bypass_containment=bool(bypass)) |
| except Exception as e: |
| return "—", f"Erreur : {e}" |
|
|
| ICONS = {Status.SUPPORTED: "✅", Status.CONTRADICTED: "❌", Status.UNKNOWN: "❓"} |
| icon = ICONS[v.status] |
| |
| if v.inference_schema: |
| origin = f"🧠 *Verdict obtenu par **inférence** (schéma `{v.inference_schema}`)*" |
| elif v.status != Status.UNKNOWN: |
| origin = "📦 *Verdict obtenu par **contenance directe** dans JDM*" |
| else: |
| origin = "" |
| status_md = (f"## {icon} {v.status.value.upper()}\n\n" |
| f"{origin}\n\n" |
| f"**Confiance** : {v.confidence:.2f}\n\n" |
| f"**Explication** : {v.explanation}") |
|
|
| lines = [] |
| |
| if v.inference_proof: |
| lines.append("**🔗 Chaîne de déduction**") |
| for e in v.inference_proof: |
| lines.append(f"- `{e.source} | {e.relation} | {e.target}` (w = {e.w:.0f})") |
| lines.append("") |
| if v.evidence_for and not v.inference_proof: |
| lines.append("**✓ Évidences en faveur**") |
| for e in v.evidence_for: |
| lines.append(f"- `{e.source} | {e.relation} | {e.target}` (w = {e.w:.0f})") |
| if v.evidence_against and not v.inference_proof: |
| lines.append("\n**✗ Évidences contraires**") |
| for e in v.evidence_against: |
| lines.append(f"- `{e.source} | {e.relation} | {e.target}` (w = {e.w:.0f})") |
| return status_md, "\n".join(lines) if lines else "*(aucun triplet cité)*" |
|
|
|
|
| |
|
|
| ANTHROPIC_MODELS = { |
| "claude-haiku-4-5": "Claude Haiku 4.5 (BYOK Anthropic)", |
| "claude-sonnet-4-5": "Claude Sonnet 4.5 (BYOK Anthropic)", |
| } |
| OPENAI_MODELS = { |
| "gpt-4o-mini": "GPT-4o mini (BYOK OpenAI)", |
| "gpt-4o": "GPT-4o (BYOK OpenAI)", |
| } |
| |
| |
| |
| |
| |
| |
| OPENAI_REASONING_MODELS: set[str] = set() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| GEMINI_MODELS = { |
| "gemini-3.1-flash-lite": "Gemini 3.1 Flash Lite (500 req/jour)", |
| "gemini-2.5-flash-lite": "Gemini 2.5 Flash Lite (20 req/jour)", |
| "gemini-3.5-flash": "Gemini 3.5 Flash (20 req/jour)", |
| } |
| |
| |
| |
| |
| |
| |
| |
| GEMINI_MODEL_ROUTING = { |
| "gemini-3.1-flash-lite": "gemini-3.1-flash-lite", |
| "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", |
| "gemini-3.5-flash": "gemini-3.5-flash", |
| } |
| |
| |
| |
| |
| GEMINI_NATIVE_REQUIRED = {"gemini-3.1-flash-lite", "gemini-3.5-flash", |
| "gemini-2.5-flash-lite"} |
| |
| |
| |
| GEMINI_THINKING_SUPPORTED = {"gemini-3.1-flash-lite", "gemini-3.5-flash"} |
| GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/" |
|
|
| |
| |
| |
| |
| |
| |
| GEMINI_POOL_PROTECTED_MODEL = "gemini-3.1-flash-lite" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| def _parse_google_keys() -> list[str]: |
| """Renvoie la liste ordonnée de clés Google API disponibles. |
| |
| Priorité 1 : variable `GOOGLE_API_KEYS` (multi-clés). |
| Priorité 2 : variable `GOOGLE_API_KEY` (singulière, rétro-compat). |
| Renvoie [] si aucune. |
| |
| Parsing TRÈS robuste : on splitte sur tout caractère qui n'est PAS |
| un caractère valide de clé Google API (alphabet `[A-Za-z0-9_-]`). |
| Couvre tous les cas observés : |
| - Séparateur classique : `,` ASCII U+002C |
| - Fullwidth comma `,` U+FF0C (claviers chinois/japonais) |
| - Arabic comma `،` U+060C |
| - Newlines, tabs, espaces, `;`, `|`, ou autres confusables |
| - Guillemets éventuels autour des clés |
| """ |
| import re |
|
|
| def _split_robust(blob: str) -> list[str]: |
| chunks = re.split(r"[^A-Za-z0-9_-]+", blob) |
| return [c for c in chunks if c] |
|
|
| raw = os.environ.get("GOOGLE_API_KEYS", "") |
| csv = raw.lstrip("").strip() |
| if csv: |
| return _split_robust(csv) |
| single_raw = os.environ.get("GOOGLE_API_KEY", "").lstrip("").strip() |
| if not single_raw: |
| return [] |
| |
| |
| |
| return _split_robust(single_raw) |
|
|
|
|
| def _current_key_index_label() -> str: |
| """Renvoie '(clé 2/4)' selon la position de _CURRENT_GEMINI_KEY |
| dans le pool. '(pool vide)' si aucune clé. Si la clé courante |
| n'est pas (ou pas encore) dans le pool → défaut '(clé 1/N)' : |
| on présume la 1ʳᵉ du CSV, qui est aussi celle qu'on utilisera |
| par défaut au prochain pick.""" |
| try: |
| keys = _parse_google_keys() |
| except Exception: |
| return "" |
| n = len(keys) |
| if n == 0: |
| return "(pool vide)" |
| cur = _CURRENT_GEMINI_KEY |
| if cur and cur in keys: |
| idx = keys.index(cur) + 1 |
| return f"(clé {idx}/{n})" |
| return f"(clé 1/{n})" |
|
|
|
|
| def _switch_key_btn_label() -> str: |
| """Label du bouton « Rotation clés gemini » avec index courant.""" |
| return f"🔄 Rotation clés gemini {_current_key_index_label()}" |
|
|
|
|
| def _masked_key(key: str) -> str: |
| """Affichage masqué d'une clé pour diagnostic (4 premiers + 4 derniers |
| chars + longueur), sans exposer la valeur entière.""" |
| if not key: |
| return "(vide)" |
| if len(key) < 12: |
| return f"« {key} » ({len(key)} chars — trop court, suspect)" |
| return f"{key[:4]}…{key[-4:]} ({len(key)} chars)" |
|
|
|
|
| def build_pool_diag_md() -> str: |
| """Bloc Markdown de DIAGNOSTIC du pool — utilisable pour debug. |
| Non injecté dans les erreurs UI par défaut.""" |
| lines = ["**État du pool Gemini** :"] |
| if _CURRENT_GEMINI_KEY: |
| lines.append(f"- Clé courante : `{_masked_key(_CURRENT_GEMINI_KEY)}`") |
| else: |
| lines.append("- Clé courante : *(aucune)*") |
| lines.append(f"- Modèle actif : `{_CURRENT_MODEL or '(aucun)'}`") |
| today = _today_utc_str() |
| blown_today = [(k, m) for (k, m, d), v in _BLOWN_TODAY.items() |
| if d == today and v] |
| if blown_today: |
| lines.append("- **Blown aujourd'hui** :") |
| for k, m in blown_today: |
| lines.append(f" - `{_masked_key(k)}` / `{m}`") |
| else: |
| lines.append("- **Blown aujourd'hui** : *(aucun)*") |
| if _INVALID_KEYS: |
| lines.append("- **Clés marquées invalides (session)** :") |
| for k in _INVALID_KEYS: |
| lines.append(f" - `{_masked_key(k)}`") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
| |
| |
| _BLOWN_TODAY: dict[tuple[str, str, str], bool] = {} |
|
|
| |
| |
| |
| |
| _INVALID_KEYS: set[str] = set() |
|
|
| |
| |
| |
| |
| |
| _REGISTRY_VERSION: int = 0 |
|
|
|
|
| def _bump_registry_version() -> None: |
| global _REGISTRY_VERSION |
| _REGISTRY_VERSION += 1 |
|
|
|
|
| def mark_gemini_key_invalid(key: str) -> None: |
| """Marque une clé comme définitivement invalide pour la session |
| (typo, révoquée, jamais activée pour Gemini, etc.).""" |
| if key and key not in _INVALID_KEYS: |
| _INVALID_KEYS.add(key) |
| _bump_registry_version() |
| _save_pool_state() |
|
|
|
|
| def _today_utc_str() -> str: |
| """Renvoie la date du jour de RESET des quotas Gemini PerDay. |
| Google reset les quotas du free tier à minuit Pacific Time (PT), |
| PAS UTC. Le nom de la fonction est resté `_utc` pour rétro-compat |
| mais l'implémentation utilise désormais America/Los_Angeles.""" |
| from datetime import datetime |
| try: |
| from zoneinfo import ZoneInfo |
| return datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") |
| except Exception: |
| |
| from datetime import timezone |
| return datetime.now(timezone.utc).strftime("%Y-%m-%d") |
|
|
|
|
| |
| |
| |
| |
| |
| _POOL_STATE_FILE = "pool_state.json" |
|
|
| |
| |
| |
| |
| _DEBUG_LAST_BUILD: dict = {} |
|
|
|
|
| def _save_pool_state() -> None: |
| """Persiste sur disque l'état blown/invalid + clé/modèle courants. |
| Best-effort, jamais bloquant (try/except global). |
| Désactivé pendant pytest pour ne pas polluer le repo entre tests.""" |
| import os as _os |
| if _os.environ.get("PYTEST_CURRENT_TEST"): |
| return |
| try: |
| import json |
| today = _today_utc_str() |
| |
| blown_list = [ |
| {"key": k, "model": m, "date": d} |
| for (k, m, d), v in _BLOWN_TODAY.items() if v |
| ] |
| payload = { |
| "version": 1, |
| "saved_at_date": today, |
| "blown": blown_list, |
| "invalid_keys": sorted(_INVALID_KEYS), |
| "current_key": _CURRENT_GEMINI_KEY, |
| "current_model": _CURRENT_MODEL, |
| } |
| with open(_POOL_STATE_FILE, "w", encoding="utf-8") as f: |
| json.dump(payload, f, ensure_ascii=False, indent=2) |
| except Exception: |
| pass |
|
|
|
|
| def _load_pool_state() -> None: |
| """Restaure l'état blown/invalid + clé/modèle courants depuis le |
| disque au démarrage du module. Filtre par date courante (Pacific |
| Time) pour ignorer les blown des jours précédents (= reset Gemini). |
| Désactivé pendant pytest pour repartir d'un état propre.""" |
| global _CURRENT_GEMINI_KEY, _CURRENT_MODEL |
| import os as _os |
| if _os.environ.get("PYTEST_CURRENT_TEST"): |
| return |
| try: |
| import json |
| import os |
| if not os.path.exists(_POOL_STATE_FILE): |
| return |
| with open(_POOL_STATE_FILE, "r", encoding="utf-8") as f: |
| payload = json.load(f) |
| today = _today_utc_str() |
| |
| |
| for entry in payload.get("blown", []): |
| d = entry.get("date") |
| k = entry.get("key") |
| m = entry.get("model") |
| if d == today and k and m: |
| _BLOWN_TODAY[(k, m, d)] = True |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| saved_key = payload.get("current_key") |
| saved_model = payload.get("current_model") |
| if saved_key: |
| pool = _parse_google_keys() |
| if (saved_key in pool |
| and saved_key not in _INVALID_KEYS |
| and not _BLOWN_TODAY.get( |
| (saved_key, GEMINI_POOL_PROTECTED_MODEL, today), False)): |
| _CURRENT_GEMINI_KEY = saved_key |
| if saved_model: |
| _CURRENT_MODEL = saved_model |
| except Exception: |
| pass |
|
|
|
|
| def mark_gemini_key_blown(key: str, model: str) -> None: |
| """Marque une (clé, modèle) comme épuisée pour aujourd'hui (PT).""" |
| if not key or not model: |
| return |
| cell = (key, model, _today_utc_str()) |
| if not _BLOWN_TODAY.get(cell, False): |
| _BLOWN_TODAY[cell] = True |
| _bump_registry_version() |
| _save_pool_state() |
|
|
|
|
| |
| |
| |
| |
| |
| _CURRENT_GEMINI_KEY: Optional[str] = None |
|
|
|
|
| def set_current_gemini_key(key: Optional[str]) -> None: |
| """Déclare la clé Gemini active. Bumpe le registry version pour |
| que le dropdown se rafraîchisse au prochain yield (le label |
| « ✅/❌ épuisé sur cette clé » dépend de la clé courante). |
| Persiste sur disque pour reprendre l'état au prochain démarrage.""" |
| global _CURRENT_GEMINI_KEY |
| if _CURRENT_GEMINI_KEY != key: |
| _CURRENT_GEMINI_KEY = key |
| _bump_registry_version() |
| _save_pool_state() |
|
|
|
|
| |
| |
| |
| _CURRENT_MODEL: Optional[str] = "gemini-3.1-flash-lite" |
|
|
|
|
| def set_current_model(model: Optional[str]) -> None: |
| """Déclare le modèle actif. Bumpe la version → le dropdown se |
| rafraîchit avec ✅ devant cette option. Persiste sur disque.""" |
| global _CURRENT_MODEL |
| if _CURRENT_MODEL != model: |
| _CURRENT_MODEL = model |
| _bump_registry_version() |
| _save_pool_state() |
|
|
|
|
| def is_model_blown_on_current_key(model: str) -> bool: |
| """True si le modèle est épuisé SUR LA CLÉ COURANTE (blown today |
| ou invalide). C'est ça qui détermine si le dropdown affiche |
| « ❌ épuisé ». Si on n'a pas encore de clé active, on retourne |
| False (état neutre).""" |
| key = _CURRENT_GEMINI_KEY |
| if not key: |
| return False |
| if key in _INVALID_KEYS: |
| return True |
| return _BLOWN_TODAY.get((key, model, _today_utc_str()), False) |
|
|
|
|
| def _refresh_dropdown_wrap(fn): |
| """Decorator/wrapper qui ajoute en DERNIÈRE position des tuples |
| yieldés un update du dropdown modèle — MAIS seulement quand |
| l'état du pool change (registry version bumpée). |
| |
| Si rien n'a changé depuis le dernier yield, on envoie un |
| `gr.update()` no-op qui ne traverse pas la frontière réseau |
| pour rebuild le dropdown. Premier tour : on snapshot la version |
| courante et on yield un update initial (pour synchroniser après |
| une rebuild de page). |
| """ |
| |
| |
| |
| |
| |
| return fn |
|
|
|
|
| def refresh_dropdowns_silent(): |
| """Helper appelé via .then() après chaque launch handler. |
| Refresh choices+value des DEUX dropdowns, sans progress bar.""" |
| cur = _CURRENT_MODEL or "gemini-3.1-flash-lite" |
| choices = build_model_choices() |
| return ( |
| gr.update(choices=choices, value=cur), |
| gr.update(choices=choices, value=cur), |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| JARVIS_BLACKLIST = {"gemini-2.5-flash-lite"} |
|
|
|
|
| def build_model_choices(for_jarvis: bool = False) -> list[tuple[str, str]]: |
| """Construit la liste de (label, value) du dropdown des modèles. |
| |
| Marquage server-side (les deux dropdowns) : |
| - ✅ devant le modèle courant (_CURRENT_MODEL) |
| - suffixe `— épuisé sur cette clé` pour les modèles Gemini natifs |
| blown sur la clé courante (le JS ajoute ❌ + grisage CSS) |
| |
| `for_jarvis=True` filtre les modèles listés dans `JARVIS_BLACKLIST` |
| (ex. Gemini 2.5 — bail en mode tool-call loop, cf. constante). |
| """ |
| import re as _re |
| out: list[tuple[str, str]] = [] |
| for key, label in ALL_MODELS.items(): |
| if for_jarvis and key in JARVIS_BLACKLIST: |
| continue |
| |
| |
| |
| |
| if key in GEMINI_MODELS and is_model_blown_on_current_key(key): |
| base = _re.sub(r"\s*\(.*?\)\s*$", "", str(label)).strip() |
| decorated = f"{base} — épuisé sur cette clé" |
| elif key == _CURRENT_MODEL: |
| decorated = f"✅ {label}" |
| else: |
| decorated = label |
| out.append((decorated, key)) |
| return out |
|
|
|
|
| def _safe_jarvis_value(model_id: str) -> str: |
| """Renvoie un model_id utilisable comme `value` du dropdown jarvis. |
| Si `model_id` est dans la blacklist (ex. user a selectionne Gemini 2.5 |
| cote chat puis ouvre Jarvis), fallback sur un default sur (3.1).""" |
| if model_id in JARVIS_BLACKLIST: |
| return "gemini-3.1-flash-lite" |
| return model_id |
|
|
|
|
| def pick_unblown_gemini_key(model: str, |
| skip: Optional[str] = None) -> Optional[str]: |
| """Renvoie une clé du pool non-épuisée pour `model`, ou None si |
| toutes blown / pool vide. `skip` exclut une clé donnée (utilisé pour |
| ne pas re-choisir celle qui vient de hit le PerDay). |
| |
| PRIORITÉ STICKY : si `_CURRENT_GEMINI_KEY` est déjà fixée pour cette |
| session ET qu'elle reste utilisable (présente dans le pool, non |
| invalide, non blown pour `model`), on la réutilise. Sans ça, chaque |
| onglet (LLM Chatbot vs Jarvis) piquait une clé différente, et les |
| marquages « blown » de l'un n'étaient pas visibles sur l'autre. Avec |
| le sticky, toute la session partage la même clé courante tant qu'elle |
| n'est pas épuisée. |
| |
| `model` est requis : chaque modèle Gemini a son propre quota PerDay, |
| donc une clé peut être blown pour un modèle et OK pour un autre. |
| |
| Exclut aussi les clés marquées comme INVALIDES (API_KEY_INVALID) |
| pour la session courante.""" |
| keys = _parse_google_keys() |
| today = _today_utc_str() |
| n = len(keys) |
| if n == 0: |
| return None |
| |
| cur = _CURRENT_GEMINI_KEY |
| if (cur and cur != skip and cur in keys |
| and cur not in _INVALID_KEYS |
| and not _BLOWN_TODAY.get((cur, model, today), False)): |
| return cur |
| |
| |
| start = 0 |
| if skip and skip in keys: |
| start = (keys.index(skip) + 1) % n |
| elif cur and cur in keys: |
| start = (keys.index(cur) + 1) % n |
| for i in range(n): |
| k = keys[(start + i) % n] |
| if k == skip: |
| continue |
| if k in _INVALID_KEYS: |
| continue |
| if not _BLOWN_TODAY.get((k, model, today), False): |
| return k |
| return None |
|
|
|
|
| def gemini_pool_size() -> int: |
| """Nombre total de clés dans le pool (utilisé pour les messages UX).""" |
| return len(_parse_google_keys()) |
|
|
| ALL_MODELS = { |
| **GEMINI_MODELS, |
| **ANTHROPIC_MODELS, **OPENAI_MODELS, |
| } |
|
|
| |
| |
| |
| _load_pool_state() |
|
|
| |
| |
| |
| if _CURRENT_GEMINI_KEY is None: |
| _initial_keys = _parse_google_keys() |
| if _initial_keys: |
| _CURRENT_GEMINI_KEY = _initial_keys[0] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| THINKING_SUPPORTED_MODELS = ( |
| GEMINI_THINKING_SUPPORTED |
| | set(ANTHROPIC_MODELS.keys()) |
| | set(OPENAI_MODELS.keys()) |
| ) |
|
|
|
|
| def _toggle_thinking_for_model(model: str): |
| """Handler Gradio : appelé sur model_in.change() / jarvis_model.change(). |
| Renvoie un gr.update pour la checkbox Raisonnement : interactive si |
| le modèle supporte le thinking, sinon décoché + grisé.""" |
| if model in THINKING_SUPPORTED_MODELS: |
| return gr.update(interactive=True) |
| return gr.update(interactive=False, value=False) |
|
|
|
|
| def _thinking_tooltip_js(checkbox_elem_id: str) -> str: |
| """Génère le JS qui met à jour le tooltip de la checkbox selon le |
| modèle sélectionné. Reçoit le model_id en argument (1er input).""" |
| return ( |
| "(model) => {" |
| f" const el = document.getElementById('{checkbox_elem_id}');" |
| " if (!el) return;" |
| f" const supported = {sorted(THINKING_SUPPORTED_MODELS)!r}.includes(model);" |
| " const tip = supported" |
| " ? 'Active le chain-of-thought sur ce modèle. Décoché : démarrage '" |
| " + 'plus rapide, comportement fonctionnel strictement identique '" |
| " + '(mêmes outils, mêmes sorties).'" |
| " : 'Non disponible pour ' + model;" |
| " el.title = tip;" |
| " el.querySelectorAll('label, input, span').forEach(c => c.title = tip);" |
| " el.dataset.tipApplied = '1';" |
| "}" |
| ) |
|
|
|
|
| def _build_llm(model: str, api_key: str, *, use_thinking: bool = True, |
| gemini_key_override: Optional[str] = None): |
| """Instancie le ChatModel selon le modèle choisi. |
| |
| - claude-* → Anthropic via clé visiteur (BYOK, sk-ant-...) |
| - gpt-* → OpenAI via clé visiteur (BYOK, sk-...) |
| - hf-* → HF Inference Providers (OpenAI-compatible) via token HF |
| (hf_...). Le modèle est routé sur le backend le plus rapide |
| pour son architecture (Cerebras pour Qwen, Together pour |
| Mistral) via le suffixe :provider du nom de modèle. |
| |
| `use_thinking` contrôle le chain-of-thought sur les modèles qui |
| supportent un raisonnement explicite (« thought summary ») : |
| - Gemini 3.x natifs : `include_thoughts=True, thinking_level="low"` |
| - Claude Sonnet/Haiku 4.5 : `thinking={"type":"enabled","budget_tokens":1024}` |
| (et `temperature=1.0`, requis par l'API) |
| - GPT-4o / GPT-4o-mini : cochable mais no-op (l'API ne supporte |
| pas `reasoning_effort` strictement) |
| - Modèles dans OPENAI_REASONING_MODELS (vide actuellement) : |
| `reasoning_effort="low"` |
| Gemini 2.x : pas de raisonnement → case grisée. Décoché : démarrage |
| plus rapide, comportement fonctionnel strictement identique |
| (mêmes outils, mêmes sorties). |
| |
| Lève ValueError avec message utilisateur explicite si la clé manque. |
| """ |
| if model.startswith("claude-"): |
| if not api_key.strip(): |
| raise ValueError( |
| "Pour utiliser un modèle Claude, colle ta clé Anthropic " |
| "(sk-ant-...) ci-dessus. Crée-en une sur " |
| "https://console.anthropic.com/settings/keys — la clé reste " |
| "dans ta session, n'est ni sauvegardée ni loggée." |
| ) |
| os.environ["ANTHROPIC_API_KEY"] = api_key.strip() |
| from jdm_agent.tools.llm_factory import get_llm |
| kwargs: dict = {} |
| if use_thinking: |
| |
| |
| |
| |
| kwargs["thinking"] = {"type": "enabled", "budget_tokens": 1024} |
| kwargs["temperature"] = 1.0 |
| return get_llm(provider="anthropic", model=model, **kwargs) |
|
|
| if model.startswith("gpt-"): |
| if not api_key.strip(): |
| raise ValueError( |
| "Pour utiliser un modèle GPT, colle ta clé OpenAI (sk-...) " |
| "ci-dessus. Crée-en une sur " |
| "https://platform.openai.com/api-keys — la clé reste dans " |
| "ta session, n'est ni sauvegardée ni loggée." |
| ) |
| os.environ["OPENAI_API_KEY"] = api_key.strip() |
| from jdm_agent.tools.llm_factory import get_llm |
| kwargs: dict = {} |
| if use_thinking and model in OPENAI_REASONING_MODELS: |
| |
| |
| |
| |
| kwargs["reasoning_effort"] = "low" |
| return get_llm(provider="openai", model=model, **kwargs) |
|
|
| |
| |
| |
| |
| if model.startswith("gemini-"): |
| |
| |
| if model in GEMINI_NATIVE_REQUIRED: |
| return _build_gemini_native( |
| model, use_thinking=use_thinking, |
| api_key_override=gemini_key_override, |
| ) |
| return _build_openai_compat( |
| model_id=model, label="Google Gemini", |
| env_var="GOOGLE_API_KEY", |
| env_var_help=( |
| "L'admin du Space doit créer une clé sur https://aistudio.google.com/apikey " |
| "(sans CB), et l'ajouter dans Settings → Variables & secrets." |
| ), |
| base_url=GEMINI_BASE_URL, routing=GEMINI_MODEL_ROUTING, |
| api_key=api_key, |
| |
| |
| override_token=gemini_key_override, |
| ) |
|
|
| raise ValueError(f"Modèle inconnu : {model!r}") |
|
|
|
|
| def _build_openai_compat(*, model_id: str, label: str, env_var: str, |
| env_var_help: str, base_url: str, routing: dict, |
| api_key: str = "", override_token: Optional[str] = None): |
| """Builder commun pour les endpoints OpenAI-compatibles (HF / Groq / Gemini). |
| |
| Le token vient de l'env (côté Space), pas du champ BYOK. Le visiteur ne |
| fournit rien — c'est gratuit pour lui, quota partagé. |
| |
| `override_token` : utilisé en PRIORITÉ sur l'env (cas du pool Gemini |
| où on veut forcer une clé spécifique pour ce call). |
| """ |
| env_token = os.environ.get(env_var, "").strip() |
| if override_token and override_token.strip(): |
| token = override_token.strip() |
| token_source = "override_token (pool)" |
| else: |
| token = env_token |
| token_source = f"env {env_var}" |
| |
| _DEBUG_LAST_BUILD.clear() |
| _DEBUG_LAST_BUILD.update({ |
| "builder": "_build_openai_compat", |
| "model_id_requested": model_id, |
| "env_var": env_var, |
| "env_var_set": bool(env_token), |
| "env_var_masked": _masked_key(env_token) if env_token else "(vide)", |
| "override_token_provided": bool(override_token), |
| "override_token_masked": (_masked_key(override_token.strip()) |
| if override_token and override_token.strip() |
| else "(non fourni)"), |
| "token_source": token_source, |
| "token_used_masked": _masked_key(token) if token else "(vide)", |
| "base_url": base_url, |
| "routed_model": routing.get(model_id, "(routing manquant)"), |
| }) |
| if not token: |
| raise ValueError( |
| f"Ce modèle nécessite un token {label} côté Space (variable " |
| f"d'environnement {env_var}). {env_var_help} En cas d'épuisement " |
| "du quota partagé, bascule sur un modèle BYOK Claude ou GPT." |
| ) |
| routed_model = routing.get(model_id) |
| if not routed_model: |
| raise ValueError(f"Modèle inconnu pour {label} : {model_id!r}") |
| from langchain_openai import ChatOpenAI |
| |
| |
| |
| |
| |
| |
| |
| temp = 1.5 if "gemini" in routed_model.lower() else 1.3 |
| return ChatOpenAI( |
| model=routed_model, |
| base_url=base_url, |
| api_key=token, |
| temperature=temp, |
| ) |
|
|
|
|
| def _build_gemini_native(model_id: str, *, use_thinking: bool = True, |
| api_key_override: Optional[str] = None): |
| """Builder spécifique pour les Gemini 3.x preview via SDK natif Google. |
| |
| Le SDK `langchain-google-genai` (qui enveloppe le SDK Python officiel |
| `google-genai`) préserve automatiquement les `thought_signature` entre |
| les tours, ce que ne fait PAS l'endpoint OpenAI-compatible — cf. |
| https://github.com/langchain-ai/langchain/issues/34056 |
| |
| Le token GOOGLE_API_KEY (côté Space, gratuit pour le visiteur) est lu |
| automatiquement par le SDK depuis l'env. |
| """ |
| |
| |
| if api_key_override: |
| token = api_key_override.strip() |
| else: |
| picked = pick_unblown_gemini_key(model_id) |
| token = picked or os.environ.get("GOOGLE_API_KEY", "").strip() |
| if not token: |
| raise ValueError( |
| "Ce modèle Gemini 3.x preview nécessite un token Google côté " |
| "Space (variable d'environnement GOOGLE_API_KEY ou pool " |
| "GOOGLE_API_KEYS). L'admin du Space doit créer une clé sur " |
| "https://aistudio.google.com/apikey (sans CB), et l'ajouter " |
| "dans Settings → Variables & secrets. Si toutes les clés du " |
| "pool sont épuisées pour aujourd'hui, bascule sur un modèle " |
| "BYOK Claude ou GPT." |
| ) |
| routed_model = GEMINI_MODEL_ROUTING.get(model_id) |
| if not routed_model: |
| raise ValueError(f"Modèle Gemini natif inconnu : {model_id!r}") |
| try: |
| from langchain_google_genai import ChatGoogleGenerativeAI |
| except ImportError as e: |
| raise ValueError( |
| "Le paquet `langchain-google-genai` est requis pour les modèles " |
| "Gemini 3.x preview. Installe-le avec : " |
| "pip install langchain-google-genai" |
| ) from e |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| base_kwargs = { |
| "model": routed_model, |
| "google_api_key": token, |
| |
| |
| |
| |
| |
| "temperature": 1.5, |
| } |
| if not use_thinking: |
| |
| |
| |
| |
| |
| |
| return ChatGoogleGenerativeAI(**base_kwargs) |
| |
| |
| |
| if model_id not in GEMINI_THINKING_SUPPORTED: |
| return ChatGoogleGenerativeAI(**base_kwargs) |
| try: |
| return ChatGoogleGenerativeAI( |
| **base_kwargs, |
| include_thoughts=True, |
| thinking_level="low", |
| ) |
| except (TypeError, ValueError): |
| return ChatGoogleGenerativeAI(**base_kwargs) |
|
|
|
|
| def _history_to_lc(history: list[dict], current_user_message: str) -> list: |
| """Convertit l'historique Gradio (format messages) en messages LangChain. |
| |
| Filtre les traces de tools / messages vides / erreurs des tours précédents |
| pour ne garder que les vraies bulles user/assistant utiles au contexte. |
| """ |
| from langchain_core.messages import AIMessage, HumanMessage |
|
|
| lc: list = [] |
| for h in history or []: |
| role = h.get("role") |
| content = (h.get("content") or "").strip() |
| if not content or content.startswith("⚠️") or content.startswith("❌"): |
| continue |
| if role == "user": |
| lc.append(HumanMessage(content=content)) |
| elif role == "assistant": |
| |
| |
| |
| answer_only = content |
| for marker in ( |
| "\n\n*(", |
| "\n\n📊 [Ouvrir la visualisation", |
| "\n\n---\n**Outils JDM appelés", |
| "\n\n---\n*Outils JDM appelés*", |
| "\n\n<iframe", |
| "\n\n<details>", |
| ): |
| answer_only = answer_only.split(marker, 1)[0] |
| answer_only = answer_only.strip() |
| if answer_only: |
| lc.append(AIMessage(content=answer_only)) |
| lc.append(HumanMessage(content=current_user_message)) |
| return lc |
|
|
|
|
| def chat_with_agent(message: str, history: list[dict], api_key: str, model: str, |
| use_thinking: bool = True): |
| """Générateur de streaming pour ChatInterface. |
| |
| Yields la trace progressive (thinking + appels d'outils + résultats) |
| puis le message final + <details> avec le raisonnement complet. |
| Même format que les flows Jarvis (cf. jarvis.run_jarvis_flow) : |
| helpers de normalisation `_content_to_text` / `_content_to_thoughts` |
| pour éviter de crasher quand m.content est une liste de blocs |
| (Gemini avec include_thoughts=True), narration lexicalisée des |
| outils connus, indicateur fugace « Génération en cours » pendant |
| le silence du LLM, <details> collapsible à la fin pour ne pas |
| polluer la réponse finale. |
| """ |
| _NOOP_FILE = gr.update() |
| if not message.strip(): |
| yield "Pose une question sur la langue française.", _NOOP_FILE |
| return |
| |
| |
| |
| |
| |
| current_gemini_key: Optional[str] = None |
| if model in GEMINI_MODELS: |
| |
| |
| |
| current_gemini_key = pick_unblown_gemini_key(model) |
| if not current_gemini_key: |
| keys = _parse_google_keys() |
| if keys: |
| current_gemini_key = keys[0] |
| set_current_gemini_key(current_gemini_key) |
| |
| set_current_model(model) |
| try: |
| llm = _build_llm(model, api_key, use_thinking=use_thinking, |
| gemini_key_override=current_gemini_key) |
| except ValueError as e: |
| yield f"⚠️ {e}", _NOOP_FILE |
| return |
|
|
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
| from langchain_core.messages import AIMessage, ToolMessage |
| from jdm_agent.enrich.validators import exclusion_context |
| from jarvis import ( |
| _content_to_text, _content_to_thoughts, |
| _narrate_tool_call, _narrate_tool_result, |
| ) |
|
|
| |
| |
| |
| progress_live: list[str] = ["*🧠 Réflexion en cours…*"] |
| progress_full: list[str] = [] |
| final_answer: str = "" |
| viz_path: Optional[str] = None |
|
|
| def _add_line(line: str) -> None: |
| progress_live.append(line) |
| progress_full.append(line) |
|
|
| yield "\n\n".join(progress_live), _NOOP_FILE |
|
|
| |
| |
| |
| |
| |
| |
| |
| import time as _time |
| from jarvis import detect_rate_limit_retry |
| agent = build_jdm_agent(client=get_client(), llm=llm) |
| accumulated_messages = _history_to_lc(history, message) |
| rate_limit_attempts = 0 |
| consecutive_rate_limit_hits = 0 |
| MAX_CONSECUTIVE_RATE_LIMIT = 3 |
| with exclusion_context(): |
| |
| |
| |
| while True: |
| try: |
| for chunk in agent.stream( |
| {"messages": accumulated_messages}, |
| stream_mode="updates", |
| ): |
| |
| |
| consecutive_rate_limit_hits = 0 |
| for _node_name, payload in chunk.items(): |
| msgs = (payload or {}).get("messages") or [] |
| for m in msgs: |
| |
| accumulated_messages.append(m) |
| if isinstance(m, AIMessage): |
| tcs = getattr(m, "tool_calls", []) or [] |
| |
| thoughts = _content_to_thoughts(m.content) |
| if thoughts.strip(): |
| t = thoughts.strip() |
| t_html = ( |
| t.replace("&", "&") |
| .replace("<", "<") |
| .replace(">", ">") |
| .replace("\n", "<br>") |
| ) |
| _add_line( |
| f'<div class="jdm-thinking">💭 {t_html}</div>' |
| ) |
| |
| spoken = _content_to_text(m.content) |
| if tcs and spoken.strip(): |
| _add_line(f"> 💬 {spoken.strip()}") |
| if tcs: |
| for tc in tcs: |
| name = tc.get("name", "?") |
| tc_args = tc.get("args") or {} |
| narrated = _narrate_tool_call(name, tc_args) |
| if narrated: |
| _add_line( |
| f'<div class="jdm-narration">' |
| f'{narrated}</div>' |
| ) |
| else: |
| args_str = ", ".join( |
| f"{k}={v!r}" |
| for k, v in tc_args.items() |
| ) |
| _add_line( |
| f'<div class="jdm-narration">' |
| f'🔧 `{name}({args_str})`</div>' |
| ) |
| live_with_pending = ( |
| "\n\n".join(progress_live) |
| + "\n\n*⏳ Génération en cours…*" |
| ) |
| yield live_with_pending, _NOOP_FILE |
| else: |
| |
| final_answer = spoken |
| elif isinstance(m, ToolMessage): |
| content = _content_to_text(m.content) |
| |
| if m.name == "build_subgraph_visualization": |
| viz_path = _extract_html_path(content) |
| narrated_done = _narrate_tool_result(m.name, content) |
| if narrated_done: |
| _add_line( |
| f'<div class="jdm-narration">' |
| f'{narrated_done}</div>' |
| ) |
| else: |
| preview = content[:140].replace("\n", " ") |
| if len(content) > 140: |
| preview += "…" |
| _add_line( |
| f'<div class="jdm-narration">' |
| f'✓ *{m.name}* renvoie {len(content)} chars : `{preview}`' |
| f'</div>' |
| ) |
| live_with_pending = ( |
| "\n\n".join(progress_live) |
| + "\n\n*⏳ Génération en cours…*" |
| ) |
| yield live_with_pending, _NOOP_FILE |
| |
| break |
| except Exception as e: |
| |
| |
| |
| |
| |
| from jarvis import is_invalid_api_key |
| if is_invalid_api_key(e): |
| if model != GEMINI_POOL_PROTECTED_MODEL: |
| yield ( |
| f"⚠️ La clé Google a renvoyé `API_KEY_INVALID` " |
| f"pour `{model}`. Cela peut indiquer un problème " |
| f"spécifique à ce modèle (pas dispo sur cette clé, " |
| f"endpoint OpenAI-compat capricieux). La clé n'est " |
| f"**PAS** marquée invalide globalement. ➡️ Bascule " |
| f"sur `{GEMINI_POOL_PROTECTED_MODEL}` ou un BYOK " |
| f"Claude/GPT.", |
| _NOOP_FILE, |
| ) |
| return |
| switched = False |
| try: |
| if current_gemini_key: |
| mark_gemini_key_invalid(current_gemini_key) |
| next_key = pick_unblown_gemini_key( |
| model, skip=current_gemini_key |
| ) |
| if next_key: |
| pool_n = gemini_pool_size() |
| current_gemini_key = next_key |
| set_current_gemini_key(current_gemini_key) |
| llm = _build_llm( |
| model, api_key, |
| use_thinking=use_thinking, |
| gemini_key_override=current_gemini_key, |
| ) |
| agent = build_jdm_agent( |
| client=get_client(), llm=llm |
| ) |
| switch_msg = ( |
| f"\n\n*🔑 Clé Google invalide détectée — " |
| f"bascule sur une autre clé du pool " |
| f"(pool : {pool_n} clés).*" |
| ) |
| current_progress = "\n\n".join(progress_live) |
| yield current_progress + switch_msg, _NOOP_FILE |
| switched = True |
| except Exception: |
| pass |
| if switched: |
| continue |
| |
| |
| |
| |
| parsed = _parse_google_keys() |
| diag = "\n".join( |
| f" - {i+1}. {_masked_key(k)}" |
| for i, k in enumerate(parsed) |
| ) or " (aucune clé parsée — vérifie GOOGLE_API_KEYS)" |
| yield ( |
| "❌ **Toutes les clés Google du pool ont échoué**.\n\n" |
| f"**Diagnostic** : {len(parsed)} clé(s) parsée(s) " |
| f"depuis `GOOGLE_API_KEYS` :\n{diag}\n\n" |
| "Vérifie ci-dessus que chaque clé a la **longueur " |
| "attendue (~39 chars)** et commence par `AIza`. " |
| "Si une clé est tronquée → problème de parsing CSV.\n\n" |
| "Sinon, causes possibles côté Google :\n" |
| "1. Clés non **activées pour l'API Generative " |
| "Language** (Google Cloud Console → APIs & Services).\n" |
| "2. Clés d'un projet sans accès aux modèles Gemini 3.x.\n" |
| "3. Quotas PerDay tous épuisés (reset minuit UTC).\n\n" |
| "Bascule sur un modèle BYOK (Claude / GPT) pour " |
| "continuer dès maintenant." |
| ), _NOOP_FILE |
| return |
| |
| |
| |
| |
| |
| |
| |
| from jarvis import is_per_day_quota_exhausted |
| if is_per_day_quota_exhausted(e, expected_model=model): |
| if current_gemini_key: |
| mark_gemini_key_blown(current_gemini_key, model) |
| |
| |
| |
| |
| if (model != GEMINI_POOL_PROTECTED_MODEL |
| and is_per_day_quota_exhausted(e, expected_model=model)): |
| try: |
| set_current_model(GEMINI_POOL_PROTECTED_MODEL) |
| except Exception: |
| pass |
| |
| |
| err_snippet = str(e)[:500].replace("`", "ʼ") |
| switch_msg = ( |
| f"⚠️ **Modèle `{model}` épuisé pour aujourd'hui** " |
| f"(quota quotidien).\n\n" |
| f"Le sélecteur est passé sur " |
| f"`{GEMINI_POOL_PROTECTED_MODEL}` (500 req/j).\n\n" |
| f"➡️ **Renvoie ton message** pour continuer avec " |
| f"`{GEMINI_POOL_PROTECTED_MODEL}`, ou choisis un " |
| f"autre modèle BYOK (Claude / GPT).\n\n" |
| f"<details><summary>Erreur API brute (debug)</summary>\n\n" |
| f"```\n{err_snippet}\n```\n</details>" |
| ) |
| yield switch_msg, _NOOP_FILE |
| return |
| if (model == GEMINI_POOL_PROTECTED_MODEL |
| and is_per_day_quota_exhausted(e, expected_model=model)): |
| switched = False |
| try: |
| if current_gemini_key: |
| mark_gemini_key_blown(current_gemini_key, model) |
| next_key = pick_unblown_gemini_key( |
| model, skip=current_gemini_key |
| ) |
| if next_key: |
| pool_n = gemini_pool_size() |
| current_gemini_key = next_key |
| set_current_gemini_key(current_gemini_key) |
| llm = _build_llm( |
| model, api_key, |
| use_thinking=use_thinking, |
| gemini_key_override=current_gemini_key, |
| ) |
| agent = build_jdm_agent( |
| client=get_client(), llm=llm |
| ) |
| switch_msg = ( |
| f"\n\n*🔄 Quota quotidien atteint sur cette " |
| f"clé Google — bascule sur une autre clé du " |
| f"pool (pool : {pool_n} clés).*" |
| ) |
| current_progress = "\n\n".join(progress_live) |
| yield current_progress + switch_msg, _NOOP_FILE |
| switched = True |
| except Exception: |
| pass |
| if switched: |
| continue |
| raise RuntimeError( |
| "Quota quotidien Gemini free tier épuisé sur " |
| "TOUTES les clés du pool (ou pool vide). Le " |
| "quota se réinitialise à minuit UTC. Réessaie " |
| "demain ou bascule sur un modèle BYOK " |
| "(Claude / GPT)." |
| ) from e |
| |
| |
| |
| |
| |
| retry_delay = detect_rate_limit_retry(e) |
| if retry_delay is not None: |
| consecutive_rate_limit_hits += 1 |
| if consecutive_rate_limit_hits >= MAX_CONSECUTIVE_RATE_LIMIT: |
| raise RuntimeError( |
| f"Quotas Gemini free tier croisés " |
| f"({consecutive_rate_limit_hits} hits PerMinute " |
| f"consécutifs sans progrès). Les fenêtres " |
| f"glissantes ne s'ouvrent jamais en même temps. " |
| f"Réessaie dans quelques minutes ou bascule sur " |
| f"un modèle BYOK (Claude / GPT)." |
| ) from e |
| rate_limit_attempts += 1 |
| wait_msg = ( |
| f"\n\n*⏳ Quota Gemini free tier atteint — j'attends " |
| f"{retry_delay:.0f}s puis je CONTINUE le travail en " |
| f"cours (pas de redémarrage).*" |
| ) |
| current_progress = "\n\n".join(progress_live) |
| yield current_progress + wait_msg, _NOOP_FILE |
| _time.sleep(retry_delay) |
| |
| |
| |
| |
| |
| from jarvis import strip_thinking_blocks |
| accumulated_messages = strip_thinking_blocks( |
| accumulated_messages, keep_last=True |
| ) |
| continue |
| |
| err_block = "" |
| if progress_full: |
| err_block = ( |
| f"\n\n<details><summary>🧠 Voir les étapes avant erreur " |
| f"({len(progress_full)})</summary>\n\n" |
| f"{(chr(10)*2).join(progress_full)}\n\n</details>" |
| ) |
| yield f"❌ Erreur agent : {e}" + err_block, _NOOP_FILE |
| return |
|
|
| |
| viz_html = _stage_viz_html(viz_path) if viz_path else None |
|
|
| |
| out = final_answer or "*(réponse vide)*" |
| if viz_path: |
| out += "\n\n📊 *Visualisation interactive disponible ci-dessous ↓*" |
| if progress_full: |
| full_text = "\n\n".join(progress_full) |
| n_steps = len(progress_full) |
| plural = "s" if n_steps > 1 else "" |
| |
| |
| if use_thinking: |
| summary_label = ( |
| f"🧠 Voir le résumé du raisonnement " |
| f"({n_steps} étape{plural})" |
| ) |
| else: |
| summary_label = f"🧠 Voir les étapes ({n_steps} étape{plural})" |
| out += ( |
| f"\n\n<details><summary>{summary_label}</summary>\n\n" |
| f"{full_text}\n\n</details>" |
| ) |
|
|
| if viz_html: |
| yield out, gr.update(value=viz_html, visible=True) |
| else: |
| yield out, _NOOP_FILE |
|
|
|
|
| def _extract_html_path(tool_message_content: str) -> Optional[str]: |
| """Extrait `html_path` d'un retour de build_subgraph_visualization. |
| |
| Le ToolMessage contient le dict sérialisé en JSON (ou en repr Python |
| selon LangChain). On essaie les deux. |
| """ |
| import json |
| import re |
| if not tool_message_content: |
| return None |
| |
| try: |
| d = json.loads(tool_message_content) |
| if isinstance(d, dict) and d.get("html_path"): |
| return str(d["html_path"]) |
| except Exception: |
| pass |
| |
| m = re.search(r"['\"]html_path['\"]\s*:\s*['\"]([^'\"]+)['\"]", tool_message_content) |
| if m: |
| return m.group(1) |
| return None |
|
|
|
|
| def _stage_viz_html(html_path: str) -> Optional[str]: |
| """Compose l'HTML du composant viz : iframe sandbox de la viz courante |
| + bouton fermer + liste de TOUTES les viz générées dans la session |
| (replie l'iframe au clic et présente la liste ; chaque entrée a un |
| bouton « voir » qui ré-ouvre l'iframe avec ce fichier, et un bouton |
| « télécharger »). |
| |
| Stratégie : on copie le fichier passé en argument dans VIZ_DIR sous |
| un nom canonique `chat_<stem>_<hash>.html`, puis on glob VIZ_DIR |
| pour bâtir l'inventaire. Garantit que la viz courante apparaît dans |
| la liste — même si l'agent l'a écrite ailleurs (CWD par défaut). |
| |
| Retourne None si la lecture du fichier courant échoue. |
| """ |
| import base64 as _b64 |
| import hashlib as _hashlib |
| import json as _json |
| import shutil as _shutil |
| from pathlib import Path as _Path |
|
|
| src = _Path(html_path) |
| if not src.exists(): |
| return None |
| try: |
| current_text = src.read_text(encoding="utf-8") |
| except Exception: |
| return None |
|
|
| |
| |
| stem = src.stem.removeprefix("chat_").removeprefix("viz_") or "viz" |
| h = _hashlib.sha1(current_text.encode("utf-8")).hexdigest()[:8] |
| canonical = VIZ_DIR / f"chat_{stem}_{h}.html" |
| if not canonical.exists(): |
| try: |
| canonical.write_text(current_text, encoding="utf-8") |
| except Exception: |
| pass |
|
|
| current_b64 = _b64.b64encode(current_text.encode("utf-8")).decode("ascii") |
|
|
| |
| |
| |
| viz_files = sorted( |
| list(VIZ_DIR.glob("chat_*.html")) + list(VIZ_DIR.glob("viz_*.html")), |
| key=lambda p: -p.stat().st_mtime, |
| ) |
| files_data = [] |
| for f in viz_files: |
| try: |
| txt = f.read_text(encoding="utf-8") |
| except Exception: |
| continue |
| b64 = _b64.b64encode(txt.encode("utf-8")).decode("ascii") |
| |
| |
| bare = f.stem.removeprefix("chat_").removeprefix("viz_") |
| nice = bare.rsplit("_", 1)[0] or bare or f.stem |
| files_data.append({ |
| "id": f.stem, |
| "label": nice, |
| "size_kb": round(f.stat().st_size / 1024, 1), |
| "b64": b64, |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| files_b64 = _b64.b64encode( |
| _json.dumps(files_data).encode("utf-8") |
| ).decode("ascii") |
|
|
| |
| |
| |
| |
| return f""" |
| <div id="viz-container" |
| style="margin:8px 0;border:1px solid var(--border-color-primary,#ddd);border-radius:8px;background:var(--block-background-fill,transparent);overflow:hidden;"> |
| <div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:var(--background-fill-secondary,#f3f4f6);border-bottom:1px solid var(--border-color-primary,#ddd);"> |
| <span style="font-weight:500;color:var(--body-text-color,#444);font-size:0.9em"> |
| 🕸️ <span id="viz-title">Visualisation interactive du sous-graphe</span> |
| <span style="color:var(--body-text-color-subdued,#999);font-size:0.85em"> |
| — zoom : molette · déplacer : glisser · double-clic : recentrer |
| </span> |
| </span> |
| <button id="viz-close-btn" onclick="window.vizClose('{files_b64}')" |
| style="background:var(--button-secondary-background-fill,#fff);border:1px solid var(--border-color-primary,#ccc);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:0.85em;color:var(--body-text-color,#444);"> |
| ✖ Fermer |
| </button> |
| </div> |
| <iframe id="viz-iframe" src="data:text/html;base64,{current_b64}" |
| style="width:100%;height:700px;border:0;background:#fff;display:block;" |
| sandbox="allow-scripts allow-same-origin"></iframe> |
| <div id="viz-list" style="display:none;padding:14px;"> |
| <div style="font-weight:500;color:var(--body-text-color,#444);margin-bottom:10px;"> |
| 📁 Visualisations générées dans cette session |
| </div> |
| <div id="viz-list-rows" style="color:var(--body-text-color,#444);"></div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| THEME = gr.themes.Soft( |
| primary_hue="violet", |
| secondary_hue="amber", |
| neutral_hue="zinc", |
| ).set( |
| |
| body_background_fill="#f4f4f5", |
| background_fill_primary="#fafafa", |
| background_fill_secondary="#f4f4f5", |
| block_background_fill="#fafafa", |
| panel_background_fill="#f4f4f5", |
| |
| input_background_fill="#ffffff", |
| |
| border_color_primary="#d4d4d8", |
| ) |
|
|
| PROJET_MD = """# JDMAgent — Démo interactive |
| |
| **Objectif** : agentification de [JeuxDeMots](https://www.jeuxdemots.org) |
| (LIRMM/CNRS, ~2 M nœuds, 180+ relations typées) pour les LLM modernes via |
| **LangChain** et le **Model Context Protocol**. |
| |
| ## Que peux-tu faire dans cette démo ? |
| |
| - **🔎 Explorer JDM** — choisis un terme et une relation, vois les triplets |
| triés par poids consensuel. Annotations sémantiques (constitutif, |
| contrastif, exception, …) optionnelles. Désambiguïsation des termes |
| polysémiques (avocat, souris, police…). |
| - **⚖️ Claim checker** — vérifie une affirmation factuelle contre JDM de |
| façon **déterministe** (sans LLM) : SUPPORTED / CONTRADICTED / UNKNOWN |
| avec citations des triplets utilisés. |
| - **🕸️ Sous-graphe** — visualisation interactive (vis-network) du |
| voisinage sémantique d'un terme jusqu'à profondeur 4, sélection de |
| relations indépendante par niveau, négations en rouge. |
| - **🤖 Agent** — conversation avec un agent (Gemini hébergé gratuit, ou |
| BYOK Claude/GPT) qui n'utilise QUE les outils JDM et cite ses sources. |
| - **🦾 Jarvis** — flux guidés par formulaires (zéro prompt à taper) : |
| - <small>🌱</small> *Enrichissement* — propose et consolide de nouveaux triplets (`.enrich`) |
| - <small>🔍</small> *Audit* — détecte les contaminations par les sens non-premiers (`.audit`) |
| - <small>🕳️</small> *Détection de trous* — flagge MISSING / NEGATIVE / LOW_COVERAGE |
| - <small>⚠️</small> *Signalement* — flagge les triplets suspects au LLM (`.err`) |
| - <small>📊</small> *Statistiques* — couverture par relation et par termes rencontrés (`.stat`) |
| |
| ## Le projet en bref |
| |
| - Couche client typée (`JDMClient`) sur l'[API JeuxDeMots](https://jdm-api.demo.lirmm.fr) |
| + cache disque + retry exponentiel. |
| - ~35 outils MCP exposés à n'importe quel client (Claude Code/Desktop, |
| Cursor, etc.) via [FastMCP](https://github.com/jlowin/fastmcp). |
| - Pipeline fact-check déterministe + détection de gaps + **moteur |
| d'inférence symbolique borné** pour la consolidation des candidats avant |
| soumission au canal contributif LLMDrops de JDM. |
| - Visualisation sous-graphe HTML autonome (vis-network) avec sélection de |
| relations par niveau, palette par famille de relation et opacité |
| progressive. |
| |
| **Données** : JeuxDeMots — Mathieu Lafourcade, équipe TEXTE, LIRMM/CNRS. |
| |
| **Liens** : |
| [Code source & README académique](https://github.com/expAg/JDMAgent) · |
| [USAGE.md](https://github.com/expAg/JDMAgent/blob/main/USAGE.md) · |
| [Notebook Colab](https://colab.research.google.com/github/expAg/JDMAgent/blob/main/notebooks/demo.ipynb) |
| """ |
|
|
|
|
| AIDE_MD = """# 🛠️ Aide & Installation |
| |
| ## 1. Naviguer dans la démo |
| |
| | Onglet | Ce qu'il fait | Clé API ? | |
| |---|---|---| |
| | 📋 **Projet** | Présentation, liens code source | Aucune | |
| | 🔎 **Explorer JDM** | Table de triplets pour un terme/relation, déterministe | Aucune | |
| | ⚖️ **Claim checker** | SUPPORTED / CONTRADICTED / UNKNOWN sur un triplet, déterministe | Aucune | |
| | 🕸️ **Sous-graphe** | Visualisation vis-network interactive du voisinage | Aucune | |
| | 🤖 **Agent** | Chat libre avec un agent LLM qui utilise les 34 outils JDM | Gemini hébergé gratuit, ou BYOK Claude / GPT | |
| | 🦾 **Jarvis** | Flows guidés par formulaires (5 sous-onglets) | Gemini hébergé gratuit ; clé LLMDrops si tu veux pousser vers JDM | |
| | 🛠️ **Aide** | Ce document | — | |
| |
| ## 2. Jarvis en détail — 5 flows guidés |
| |
| Tous les sous-onglets Jarvis partagent un **bandeau** en haut : |
| - **Clé API LLMDrops** (optionnel) : override l'env `JDM_DROPS_API_KEY` pour les uploads. |
| - **Modèle LLM** : Gemini 3.1 Flash Lite par défaut (500 requêtes/jour gratuites). BYOK Claude / GPT possibles si tu colles ta clé. |
| - **Budget d'appels d'outils** : 10 / 25 / 50 / 100 / illimité. Au-delà, le LLM reçoit un sentinel et arrête proprement en consolidant ce qu'il a. |
| |
| ### 🌱 Enrichissement |
| Propose et consolide de nouveaux triplets pour un terme. |
| - **Form** : terme, relation cible (optionnelle), nombre cible de triplets, varier les relations, itérer jusqu'au but, soumettre directement. |
| - **Output** : chatbot avec le raisonnement + le fichier `.enrich` écrit. |
| - **Workflow** : `enrichment_workflow()` (pré-fetch → désambiguïsation → proposition → validation+consolidation par inférence → écriture). |
| |
| ### 🔍 Audit |
| Audit sémantique de la répartition des sens d'un terme polysémique. |
| - **Form** : terme, relation cible optionnelle, soumettre directement. |
| - **Output** : verdict par triplet du terme générique (LEGITIME / DEVRAIT_ETRE_CONTRASTIF / NON_CONTRASTIF / NEGATIVE) + section META narrative. |
| - **Workflow** : `audit_workflow()`. |
| |
| ### 🕳️ Détection de trous |
| Identifie les trous de couverture (MISSING / NEGATIVE_FILLED / LOW_COVERAGE). |
| - **Form** : terme, relations à examiner (vide = défauts), seuil LOW_COVERAGE. |
| - **Output gauche** : tableau des gaps trouvés (déterministe, instantané) + dropdown pour router un gap → boutons **→ Enrichir** / **→ Auditer** / **→ Stats** qui pré-remplissent les autres sous-onglets et basculent l'onglet. |
| - **Output droite** : synthèse narrative de l'agent. |
| - **Workflow** : `gap_detection_workflow()`. |
| |
| ### ⚠️ Signalement |
| Le LLM utilise son **jugement linguistique** pour flagger les triplets suspects (pas besoin de preuve d'outil). |
| - **Form** : terme, relation optionnelle, soumettre directement. |
| - **Output** : fichier `.err` avec catégorie de suspicion et justification. |
| - **Workflow** : `signalement_workflow()`. |
| |
| ### 📊 Stats |
| Statistiques de couverture par terme et/ou par relation. |
| - **Form** : terme (mode PAR_TERME), relation (mode PAR_RELATION) — au moins un des deux. |
| - **Output** : tableau (n_total, n_pos, n_neg, max_w, min_w, mean_w par relation) + 3-5 observations clés. |
| - **Workflow** : `stats_workflow()`. |
| |
| ## 3. Obtenir les clés API |
| |
| | Clé | Où ? | Coût | Quand l'utiliser | |
| |---|---|---|---| |
| | **Gemini** | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | Gratuit (500 req/jour pour 3.1 Flash Lite) | Pré-configurée côté HF Space, rien à faire pour toi | |
| | **LLMDrops JDM** | jeuxdemots.org (contacter M. Lafourcade) | Gratuit sur demande | Soumettre `.enrich` / `.audit` / `.err` directement à JDM | |
| | **Anthropic (Claude)** | [console.anthropic.com](https://console.anthropic.com) | Payant ($) | BYOK Claude dans Agent / Jarvis | |
| | **OpenAI (GPT)** | [platform.openai.com](https://platform.openai.com/api-keys) | Payant ($) | BYOK GPT dans Agent / Jarvis | |
| |
| ⚠️ **Sécurité** : les clés que tu colles dans l'UI ne sont **jamais persistées** côté serveur — elles vivent uniquement le temps de ton onglet navigateur. |
| |
| ## 4. Installation locale (déployer la même app ailleurs) |
| |
| Recette complète pour faire tourner la démo Gradio sur ta machine (Linux / macOS / Windows) ou sur un serveur (LIRMM, VPS, etc.) : |
| |
| ```bash |
| # 1. Cloner le repo |
| git clone https://github.com/expAg/JDMAgent.git |
| cd JDMAgent |
| |
| # 2. Créer un environnement Python isolé (venv) dans le repo |
| # Sur Debian/Ubuntu : sudo apt install python3-venv si pas déjà là |
| python3 -m venv .venv |
| |
| # 3. Activer le venv |
| source .venv/bin/activate # Linux / macOS |
| # .venv\\Scripts\\activate # Windows (cmd / PowerShell) |
| |
| # 4. Installer les dépendances |
| pip install --upgrade pip |
| pip install -r requirements.txt |
| |
| # 5. Configurer les clés API (copie le template puis édite) |
| cp .env.example .env |
| # édite .env : ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY / |
| # GROQ_API_KEY / DEEPSEEK_API_KEY / JDM_DROPS_API_KEY / LLM_PROVIDER / |
| # LLM_MODEL — seules les clés des providers que tu veux utiliser sont |
| # obligatoires, le reste peut rester vide. |
| |
| # 6. Lancer l'app — écoute sur http://0.0.0.0:7860 |
| python app.py |
| ``` |
| |
| Ensuite, dans ton navigateur → <http://localhost:7860> (ou l'IP du serveur sur le port 7860 si déploiement distant). |
| |
| **Sur Debian 12 / Ubuntu 24.04 (PEP 668)** : pip refuse d'installer hors venv — le venv ci-dessus est donc **obligatoire**, pas optionnel. Ne contourne pas avec `--break-system-packages` (casse les outils OS). |
| |
| **Pour ré-utiliser sans tout retaper** : `cd /chemin/JDMAgent && source .venv/bin/activate && python app.py`. Ou en service systemd, invoque directement `.venv/bin/python app.py` (pas besoin d'activate). |
| |
| Voir [USAGE.md](https://github.com/expAg/JDMAgent/blob/main/USAGE.md) pour les détails (CLI, MCP, fact-check programmatique). |
| |
| ## 5. Serveur MCP — utiliser les 34 outils JDM dans Claude Code / Cursor |
| |
| ```bash |
| # Installation locale (stdio) |
| claude mcp add jdm "python -m jdm_agent.mcp.server" |
| |
| # Vérification |
| claude mcp list |
| ``` |
| |
| Ensuite, depuis Claude Code : « Donne-moi les synonymes de voiture dans JDM » → l'agent appelle automatiquement les outils MCP exposés. |
| |
| ## 6. Format des fichiers de soumission |
| |
| Tous les fichiers produits par Jarvis suivent un **format pipe** : |
| |
| ``` |
| # .enrich (proposition de triplets) |
| term | relation | target | annotation < explication chaîne d'inférence > |
| |
| # .audit (deux sections séparées par === META ===) |
| === PROPOSITIONS === |
| term | relation | target | annotation | verdict | justification |
| ... |
| === META === |
| <compte rendu narratif sur la confusion / propagation des sens> |
| |
| # .err (suspects flaggés par le LLM) |
| term | relation | target | catégorie_suspect | justification |
| ``` |
| |
| Le LLM produit ces fichiers en local. Pour les pousser à JDM, soit : |
| - coche **Soumettre directement** dans le formulaire (la clé `JDM_DROPS_API_KEY` doit être configurée) ; |
| - ou télécharge le fichier puis poste-le manuellement sur le formulaire LLMDrops de jeuxdemots.org. |
| |
| ## 7. Liens utiles |
| |
| - **Code source** : <https://github.com/expAg/JDMAgent> |
| - **API JeuxDeMots** : <https://jdm-api.demo.lirmm.fr> |
| - **JeuxDeMots (site)** : <https://www.jeuxdemots.org> |
| - **USAGE.md détaillé** : <https://github.com/expAg/JDMAgent/blob/main/USAGE.md> |
| - **DEVELOPMENT.md** : <https://github.com/expAg/JDMAgent/blob/main/DEVELOPMENT.md> |
| """ |
|
|
|
|
| |
|
|
| import base64 as _b64 |
| import tempfile |
|
|
| |
| |
| |
| |
| |
| |
| PRODUCTIONS_DIR = Path("/tmp/jdm_outputs") |
| PRODUCTIONS_DIR.mkdir(parents=True, exist_ok=True) |
| |
| |
| VIZ_DIR = PRODUCTIONS_DIR |
|
|
| |
| |
| JARVIS_RELATIONS: list[str] = list(DEFAULT_RELATIONS) + [ |
| r for r in ( |
| "r_syn", "r_anto", "r_agent-1", "r_patient-1", "r_instr-1", |
| "r_telic_role", "r_lieu", "r_has_color", "r_has_part", |
| "r_make", "r_processus>agent", "r_processus>patient", |
| "r_has_conseq", "r_has_causatif", "r_domain", "r_associated", |
| ) |
| if r not in DEFAULT_RELATIONS |
| ] |
|
|
|
|
| def viz_subgraph(term: str, depth: float, |
| top_k: float, top_k_d2: float, top_k_d3: float, top_k_d4: float, |
| selected_relations: list[str], |
| selected_depth2_relations: list[str], |
| selected_depth3_relations: list[str], |
| selected_depth4_relations: list[str]): |
| """Construit un sous-graphe et renvoie (status, html_inline, file_for_download). |
| |
| Stratégie multi-fallback : |
| - **HTML inline** via iframe data:base64 (marche si DOMPurify autorise data:) |
| - **Téléchargement** du même fichier via gr.File (toujours dispo, plan B sûr) |
| - Logs côté serveur (visibles dans HF Spaces) pour diagnostic en cas d'écran blanc. |
| """ |
| term = (term or "").strip() |
| if not term: |
| return "⚠️ Saisis un terme.", "", None |
| |
| term, _err = _resolve_and_check(get_client(), term) |
| if _err: |
| return f"⚠️ {_err}", "", None |
| rels = selected_relations if selected_relations else None |
| d2_rels = selected_depth2_relations if selected_depth2_relations else None |
| d3_rels = selected_depth3_relations if selected_depth3_relations else None |
| d4_rels = selected_depth4_relations if selected_depth4_relations else None |
| try: |
| cache_key = (term, depth, top_k, top_k_d2, top_k_d3, top_k_d4, |
| tuple(rels or ()), tuple(d2_rels or ()), |
| tuple(d3_rels or ()), tuple(d4_rels or ())) |
| |
| |
| |
| |
| import time as _time_mod_viz |
| _safe_term = "".join(ch if ch.isalnum() or ch in "_-" else "_" |
| for ch in (term or "x"))[:40] |
| _ts_short = _time_mod_viz.strftime("%H%M%S") |
| out_path = VIZ_DIR / f"viz_{_safe_term}_{_ts_short}.html" |
| |
| _i = 2 |
| while out_path.exists(): |
| out_path = VIZ_DIR / f"viz_{_safe_term}_{_ts_short}_{_i}.html" |
| _i += 1 |
| print(f"[viz] term={term!r} depth={depth} " |
| f"top_k=[{top_k},{top_k_d2},{top_k_d3},{top_k_d4}] " |
| f"rels={rels} d2={d2_rels} d3={d3_rels} d4={d4_rels}", flush=True) |
| res = build_subgraph( |
| term, |
| client=get_client(), |
| depth=int(depth), |
| top_k_per_relation=int(top_k), |
| top_k_depth2=int(top_k_d2), |
| top_k_depth3=int(top_k_d3), |
| top_k_depth4=int(top_k_d4), |
| relations=rels, |
| depth2_relations=d2_rels, |
| depth3_relations=d3_rels, |
| depth4_relations=d4_rels, |
| output="html", |
| output_path=str(out_path), |
| ) |
| s = res["stats"] |
| print(f"[viz] generated {s['n_nodes']} nodes / {s['n_edges']} edges -> {out_path}", |
| flush=True) |
| html_text = out_path.read_text(encoding="utf-8") |
| b64 = _b64.b64encode(html_text.encode("utf-8")).decode("ascii") |
| iframe = ( |
| f'<iframe src="data:text/html;base64,{b64}" ' |
| f'style="width:100%;height:910px;border:1px solid #ddd;' |
| f'border-radius:8px;background:#fff;display:block;" ' |
| f'sandbox="allow-scripts allow-same-origin"></iframe>' |
| ) |
| status = ( |
| f"✅ **{s['n_nodes']} nœuds**, **{s['n_edges']} arêtes** " |
| f"(dont **{s['n_negative']} négations** en rouge) — profondeur {s['depth']}.\n\n" |
| f"*Si le graphe ne s'affiche pas inline ci-dessous, " |
| f"télécharge le fichier HTML et ouvre-le dans ton navigateur.*" |
| ) |
| return status, iframe, str(out_path) |
| except Exception as e: |
| import traceback |
| tb = traceback.format_exc() |
| print(f"[viz] ERROR: {e}\n{tb}", flush=True) |
| return f"❌ Erreur : {e}\n\n```\n{tb}\n```", "", None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _HEAD_JS = """ |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <script> |
| (function() { |
| // ---------- Gate admin (URL flag ?admin=1) ---------- |
| // Si l'URL contient ?admin=1, on ajoute la classe .admin-revealed |
| // sur CHAQUE element .admin-only (pas sur <body>). Le CSS associe |
| // (cf. _CHATBOT_CSS) revele alors ces blocs. |
| // |
| // POURQUOI cette double-classe au lieu d'un selector d'ancetre : |
| // Gradio v5 scope automatiquement le CSS injecte via le param `css=` |
| // en prefixant chaque regle avec `gradio-container.gradio-container-X.X.X |
| // .contain `. Du coup un selector comme `body.admin-mode .admin-only` |
| // devient `gradio-container... .contain .admin-mode .admin-only` — |
| // qui demande que .admin-mode soit DESCENDANT de .contain. Or le body |
| // est ANCETRE de .contain, donc le selector ne matche jamais. |
| // En mettant la classe .admin-revealed sur l'element .admin-only |
| // lui-meme, on evite tout selector d'ancetre — la regle CSS |
| // `.admin-only.admin-revealed` resiste au scoping de Gradio. |
| try { |
| var qs = new URLSearchParams(window.location.search || ''); |
| if (qs.get('admin') === '1') { |
| var revealAdmin = function() { |
| document.querySelectorAll('.admin-only').forEach(function(el) { |
| if (!el.classList.contains('admin-revealed')) { |
| el.classList.add('admin-revealed'); |
| } |
| }); |
| }; |
| if (document.readyState !== 'loading') revealAdmin(); |
| else document.addEventListener('DOMContentLoaded', revealAdmin); |
| // Observer pour les .admin-only rendus apres le DOMContentLoaded |
| // (Gradio v5 monte la UI de facon asynchrone). |
| new MutationObserver(revealAdmin).observe( |
| document.body || document.documentElement, |
| { childList: true, subtree: true } |
| ); |
| } |
| } catch (e) { /* navigateurs anciens : on ignore */ } |
| |
| // ---------- Productions : tooltip natif + marquage soumis ---------- |
| // Pour chaque label des CheckboxGroup productions (recent + oldies) : |
| // - ajoute un title= avec le texte complet (hover natif HTML) |
| // - si le label commence par ✅ (= deja soumis a JDM), ajoute la |
| // classe .prod-submitted pour declencher le styling CSS. |
| function refreshProdLabels(root) { |
| root.querySelectorAll( |
| '#prod-file-radio label, #prod-oldies-radio label' |
| ).forEach(function(lbl) { |
| var span = lbl.querySelector('span'); |
| var txt = (span ? span.textContent : lbl.textContent || '').trim(); |
| if (!txt) return; |
| if (lbl.title !== txt) lbl.title = txt; |
| var isSubmitted = txt.startsWith('✅'); |
| lbl.classList.toggle('prod-submitted', isSubmitted); |
| }); |
| } |
| document.addEventListener('DOMContentLoaded', function() { |
| refreshProdLabels(document); |
| }); |
| new MutationObserver(function() { |
| refreshProdLabels(document); |
| }).observe(document.body || document.documentElement, { |
| childList: true, subtree: true |
| }); |
| |
| // ---------- Style parenthèses des dropdowns (quotas, BYOK) ---------- |
| // Trouve toute option dont le texte se termine par « (X req/jour) » ou |
| // « (BYOK ...) » et entoure cette partie d'un span gris/petit. |
| // MutationObserver pour rattraper les options rendues à l'ouverture |
| // du dropdown. |
| var PAREN_RE = /\\s*\\((\\d+\\s*req\\/jour|BYOK\\s+[^)]+)\\)\\s*$/; |
| function styleParens(root) { |
| root.querySelectorAll( |
| 'li[role="option"], div[role="option"], [data-testid*="dropdown"] li, ul[role="listbox"] li' |
| ).forEach(function(opt) { |
| if (opt.dataset.parenStyled) return; |
| var text = opt.textContent || ''; |
| var m = text.match(PAREN_RE); |
| if (!m) return; |
| var main = text.slice(0, m.index).trim(); |
| opt.innerHTML = main + |
| ' <span style="color:var(--body-text-color-subdued,#999);font-size:0.85em;font-weight:normal;">' + |
| m[0].trim() + '</span>'; |
| opt.dataset.parenStyled = '1'; |
| }); |
| } |
| document.addEventListener('DOMContentLoaded', function() { styleParens(document); }); |
| new MutationObserver(function(muts) { |
| muts.forEach(function(m) { |
| m.addedNodes.forEach(function(n) { |
| if (n.nodeType === 1) styleParens(n); |
| }); |
| }); |
| }).observe(document.body, { childList: true, subtree: true }); |
| |
| function parseB64Json(b64) { |
| try { |
| return JSON.parse(decodeURIComponent(escape(atob(b64)))); |
| } catch (e) { |
| console.error('viz: parse failed', e); |
| return []; |
| } |
| } |
| |
| function esc(s) { |
| return String(s).replace(/[&<>"']/g, function(c) { |
| return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; |
| }); |
| } |
| |
| // vizClose reçoit le base64 du JSON des fichiers EN ARGUMENT (passé |
| // directement depuis l'onclick HTML — seule méthode robuste face à |
| // DOMPurify de Gradio qui strippe data-*, <pre>, <script>, etc.). |
| // On stocke la data parsée sur le container pour les vizOpen suivants. |
| window.vizClose = function(b64) { |
| var ifr = document.getElementById('viz-iframe'); |
| var list = document.getElementById('viz-list'); |
| var rows = document.getElementById('viz-list-rows'); |
| var btn = document.getElementById('viz-close-btn'); |
| var c = document.getElementById('viz-container'); |
| if (!ifr || !list || !rows || !btn || !c) return; |
| var data = b64 ? parseB64Json(b64) : (c.__vizFiles || []); |
| c.__vizFiles = data; // mémorise pour vizOpen |
| ifr.style.display = 'none'; |
| btn.style.display = 'none'; |
| list.style.display = 'block'; |
| if (data.length === 0) { |
| rows.innerHTML = '<div style="color:var(--body-text-color-subdued,#999);font-style:italic;padding:6px 0;">Aucune visualisation dans la session pour l\\'instant.</div>'; |
| return; |
| } |
| rows.innerHTML = data.map(function(f, idx) { |
| return '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border-color-primary,#eee);">' |
| + '<a href="#" onclick="window.vizOpen(' + idx + ');return false;" style="color:var(--link-text-color,#7c3aed);text-decoration:none;font-weight:500;flex:1;">' |
| + esc(f.label) + '</a>' |
| + '<span style="color:var(--body-text-color-subdued,#999);font-size:0.85em;display:flex;align-items:center;gap:10px;">' |
| + '<span>' + f.size_kb + ' KB</span>' |
| + '<a href="data:text/html;base64,' + f.b64 + '" download="' + esc(f.id) + '.html"' |
| + ' style="color:var(--body-text-color,#666);text-decoration:none;border:1px solid var(--border-color-primary,#ccc);border-radius:4px;padding:3px 10px;font-size:0.85em;background:var(--button-secondary-background-fill,#fff);">⬇ Télécharger</a>' |
| + '</span>' |
| + '</div>'; |
| }).join(''); |
| }; |
| |
| window.vizOpen = function(idx) { |
| var ifr = document.getElementById('viz-iframe'); |
| var list = document.getElementById('viz-list'); |
| var btn = document.getElementById('viz-close-btn'); |
| var title = document.getElementById('viz-title'); |
| var c = document.getElementById('viz-container'); |
| if (!ifr || !list || !btn || !c) return; |
| var data = c.__vizFiles || []; |
| if (!data[idx]) return; |
| ifr.src = 'data:text/html;base64,' + data[idx].b64; |
| if (title) title.textContent = data[idx].label; |
| ifr.style.display = 'block'; |
| btn.style.display = 'inline-block'; |
| list.style.display = 'none'; |
| }; |
| |
| // ---------- Chatbot auto-resize (content-aware) ---------- |
| // Gradio v5 gr.Chatbot a une hauteur fixe par défaut. On observe les |
| // changements de contenu et on adapte dynamiquement la hauteur du |
| // .bubble-wrap : |
| // - vide (0 message) → 90 px (compact) |
| // - rempli → grandit avec le contenu jusqu'à 85vh (≈ pleine fenêtre) |
| // Centrage : à chaque nouveau message, on scroll le chatbot dans la |
| // vue centrée verticalement → l'utilisateur n'a presque pas à scroller. |
| function resizeChatbot() { |
| var root = document.getElementById('agent-chatbot'); |
| if (!root) return; |
| var wrap = root.querySelector('.bubble-wrap') |
| || root.querySelector('[class*="bubble-wrap"]') |
| || root.querySelector('.wrap'); |
| if (!wrap) return; |
| // Compte les messages réels |
| var msgs = root.querySelectorAll('.message, [class*="message-row"], [data-testid="bot"], [data-testid="user"]'); |
| var isEmpty = msgs.length === 0; |
| var maxH = Math.floor(window.innerHeight * 0.85); |
| var minH = isEmpty ? 90 : 200; |
| // Hauteur naturelle du contenu (scrollHeight) bornée par max |
| wrap.style.height = 'auto'; |
| wrap.style.maxHeight = maxH + 'px'; |
| wrap.style.minHeight = minH + 'px'; |
| // Si contenu présent et qu'il pousse au-delà du minH, on suit le |
| // scrollHeight pour ne pas créer une scrollbar inutile. |
| if (!isEmpty) { |
| var natural = wrap.scrollHeight; |
| var target = Math.min(natural, maxH); |
| if (target > minH) wrap.style.height = target + 'px'; |
| } else { |
| wrap.style.height = minH + 'px'; |
| } |
| |
| // ---- Masquer les exemples dès qu'un message existe ---- |
| // gr.ChatInterface rend les exemples comme un panneau séparé sous |
| // le chat. On le repère par plusieurs sélecteurs candidats (la |
| // classe exacte change entre versions Gradio v5.x). On remonte au |
| // parent .block pour cacher tout le bloc, pas juste le contenu. |
| try { |
| var sels = [ |
| '.examples-holder', |
| '.examples-table', |
| '[data-testid="examples"]', |
| '.examples' |
| ]; |
| var ex = null; |
| for (var i = 0; i < sels.length; i++) { |
| ex = document.querySelector(sels[i]); |
| if (ex) break; |
| } |
| if (ex) { |
| var block = ex.closest('.block') || ex.parentElement || ex; |
| block.style.display = isEmpty ? '' : 'none'; |
| } |
| } catch (e) { /* silently ignore selector failures */ } |
| } |
| |
| // Debounce du scroll : on attend que les mutations se calment (~250 ms) |
| // avant de scroller — sinon on scroll à chaque chunk de streaming. Le |
| // viz scroll fire à 300/700/1200 ms via viz_html_out.change() et |
| // l'emporte si une viz a été générée. |
| // |
| // CIBLE : la barre de saisie (textarea du chat) — elle DOIT rester |
| // collée au bas de la fenêtre, pour que l'utilisateur réponde sans |
| // scroller. Le chatbot rempli au-dessus se cale naturellement |
| // (jusqu'à 85vh, scroll interne au-delà). |
| var _scrollTimer = null; |
| function findChatInput() { |
| // Stratégie multi-fallback (la classe Gradio change entre versions). |
| var root = document.getElementById('agent-chatbot'); |
| if (root) { |
| var section = root.closest('[class*="chat-interface"]') |
| || root.closest('.tabitem') |
| || root.parentElement; |
| if (section) { |
| // 1) textarea avec placeholder « message » / « Type » |
| var ta = section.querySelector( |
| 'textarea[placeholder*="message" i], textarea[placeholder*="Type" i]' |
| ); |
| if (ta) return ta; |
| // 2) Fallback : tout textarea visible dans la section |
| var allTa = section.querySelectorAll('textarea'); |
| for (var i = 0; i < allTa.length; i++) { |
| if (allTa[i].offsetParent !== null) return allTa[i]; |
| } |
| } |
| } |
| // 3) Dernier recours : n'importe quel textarea avec placeholder type message |
| return document.querySelector( |
| 'textarea[placeholder*="message" i], textarea[placeholder*="Type" i]' |
| ); |
| } |
| function scrollChatIntoView() { |
| clearTimeout(_scrollTimer); |
| _scrollTimer = setTimeout(function() { |
| // Si une viz est visible, on NE scroll PAS — la viz scroll s'en |
| // chargera quelques ms plus tard. Évite le saut chat→viz. |
| var viz = document.getElementById('viz-container'); |
| if (viz && viz.offsetParent !== null) return; |
| var input = findChatInput(); |
| if (!input) return; |
| // block:'end' colle l'input contre le bas du viewport ; le chatbot |
| // (au-dessus) occupe le reste de la fenêtre, l'utilisateur voit |
| // les derniers messages + la zone de saisie sans scroller. |
| input.scrollIntoView({ behavior: 'smooth', block: 'end' }); |
| }, 250); |
| } |
| |
| // Lance au load, puis observe le DOM du chatbot pour réagir aux |
| // ajouts/suppressions de messages (et streaming progressif). |
| function bindChatbotObserver() { |
| var root = document.getElementById('agent-chatbot'); |
| if (!root || root.__resizeBound) { |
| // Pas encore monté — retry au prochain tick |
| if (!root) { setTimeout(bindChatbotObserver, 400); return; } |
| return; |
| } |
| root.__resizeBound = true; |
| resizeChatbot(); |
| new MutationObserver(function() { |
| resizeChatbot(); |
| // À chaque mutation du chatbot (nouveau message, streaming chunk), |
| // on (re)déclenche le scroll centré — debouncé, donc une seule |
| // exécution au calme. |
| var msgs = root.querySelectorAll('.message, [class*="message-row"], [data-testid="bot"], [data-testid="user"]'); |
| if (msgs.length > 0) scrollChatIntoView(); |
| }).observe(root, { childList: true, subtree: true, characterData: true }); |
| window.addEventListener('resize', resizeChatbot); |
| } |
| document.addEventListener('DOMContentLoaded', bindChatbotObserver); |
| // Filet de sécurité : Gradio rend les composants après DOMContentLoaded |
| setTimeout(bindChatbotObserver, 800); |
| setTimeout(bindChatbotObserver, 2000); |
| |
| // ---------- Onglets « Aide » et « Drops » flush à droite ---------- |
| // CSS pur ne marche pas de façon fiable (structure DOM Gradio v5 |
| // varie). On cherche tous les boutons role="tab" qui contiennent |
| // un des labels cibles (Aide en top-level, Drops en sous-onglet |
| // Jarvis) et on leur applique margin-left:auto pour les pousser |
| // au bout à droite de leur tablist parent. |
| function pushAideTabRight() { |
| var tabs = document.querySelectorAll('[role="tab"]'); |
| var done = false; |
| var targets = ['Aide', 'Drops']; |
| for (var i = 0; i < tabs.length; i++) { |
| var label = (tabs[i].textContent || '').trim(); |
| for (var t = 0; t < targets.length; t++) { |
| if (label.indexOf(targets[t]) >= 0) { |
| tabs[i].style.marginLeft = 'auto'; |
| done = true; |
| break; |
| } |
| } |
| } |
| return done; |
| } |
| |
| // ---------- Tab « Jarvis » coloré ---------- |
| // Gradio rend le label de gr.Tab en TEXTE PLAT (pas markdown/HTML). |
| // Pour mettre « Jarvis » en couleur, on cherche le tab contenant |
| // « Jarvis » et on remplace son innerHTML par une version avec span |
| // coloré. Marqueur dataset pour éviter de remplacer plusieurs fois. |
| // L'espace insécable garantit la séparation Agent | Jarvis |
| // (parfois mangée par Gradio quand le label devient innerHTML). |
| // Couleur dorée chaude (#f5b042) qui s'accorde avec l'amber du thème |
| // secondary_hue et fait un contraste agréable avec le gris-bleu du |
| // robot 🤖 (au lieu du cyan bleuté qui rentrait en conflit). |
| function colorJarvisTab() { |
| var tabs = document.querySelectorAll('[role="tab"]'); |
| for (var i = 0; i < tabs.length; i++) { |
| var t = tabs[i]; |
| if (t.dataset.jarvisStyled) continue; |
| var txt = (t.textContent || ''); |
| if (txt.indexOf('Jarvis') < 0) continue; |
| // Force un espace insécable AVANT Jarvis pour éviter le collage. |
| // Pas de font-weight : il rendait les glyphes visuellement plus |
| // larges → impression de taille différente vs « Agent ». La |
| // couleur seule suffit à distinguer. |
| t.innerHTML = t.innerHTML.replace( |
| /\\s*Jarvis/g, |
| ' <span style="color:#f5b042;">Jarvis</span>' |
| ); |
| t.dataset.jarvisStyled = '1'; |
| } |
| } |
| |
| // Tooltip natif (attribut title) sur les checkboxes « Raisonnement |
| // Gemini ». Le texte explicite (« comportement identique, démarrage |
| // plus rapide ») apparait au survol — pas de bloc verbeux sous le |
| // label. |
| function applyThinkingTooltip() { |
| var ids = ['jarvis-thinking-cb', 'chat-thinking-cb']; |
| var tip = 'Active le chain-of-thought sur les modèles qui le supportent ' |
| + '(Gemini 3.x, Claude Sonnet/Haiku 4.5). Décoché : démarrage ' |
| + 'plus rapide, comportement fonctionnel strictement identique ' |
| + '(mêmes outils, mêmes sorties — seule la narration interne du ' |
| + 'raisonnement n\\'est pas demandée à l\\'API). Sans effet sur ' |
| + 'Gemini 2.x (pas de raisonnement natif).'; |
| for (var i = 0; i < ids.length; i++) { |
| var el = document.getElementById(ids[i]); |
| if (el && !el.dataset.tipApplied) { |
| el.title = tip; |
| // Et sur tous les enfants (label, input) pour que le hover soit |
| // déclenché partout sur la case. |
| var children = el.querySelectorAll('label, input, span'); |
| for (var j = 0; j < children.length; j++) children[j].title = tip; |
| el.dataset.tipApplied = '1'; |
| } |
| } |
| } |
| |
| // Pour chaque option de dropdown dont le label contient |
| // « épuisé sur cette clé » : |
| // - insère un ❌ devant le ✓ natif Gradio (ou en début d'option si |
| // le ✓ n'est pas trouvé) ; |
| // - masque le ✓ natif (display:none) pour éviter ❌ et ✓ côte à côte ; |
| // - applique opacity 0.45 + couleur grisée à l'option entière pour |
| // marquer visuellement « non utilisable ». |
| // S'applique à TOUS les dropdowns (LLM Chatbot + Jarvis) — la |
| // détection se fait par texte, peu importe l'arbre DOM. |
| function replaceCheckOnBlownOptions() { |
| // Sélecteurs très larges : Gradio v5 rend les options dans des |
| // portails (souvent attachés au body) avec des marqueurs variables. |
| var nodes = document.querySelectorAll( |
| '[role="option"], li[role="option"], ul[role="listbox"] li, ' + |
| '.options li, .options [data-value], [data-testid="dropdown"] li' |
| ); |
| nodes.forEach(function(opt) { |
| var text = (opt.textContent || '').trim(); |
| var isBlown = text.indexOf('épuisé sur cette clé') !== -1; |
| // Marker existant ? |
| var existing = opt.querySelector('.jdm-x-marker'); |
| if (isBlown) { |
| // Grisage de l'option |
| opt.style.opacity = '0.45'; |
| opt.style.color = '#9aa0a6'; |
| opt.dataset.jdmBlown = '1'; |
| if (!existing) { |
| var x = document.createElement('span'); |
| x.textContent = '❌ '; |
| x.className = 'jdm-x-marker'; |
| x.style.marginRight = '4px'; |
| x.style.display = 'inline-block'; |
| // Insertion au début de l'option (avant ✓ et label) |
| opt.insertBefore(x, opt.firstChild); |
| } |
| // Masque ✓ natif Gradio (svg, .checkmark, etc.) |
| var ticks = opt.querySelectorAll('svg.checkmark, span.checkmark, .check-icon'); |
| ticks.forEach(function(ic) { ic.style.display = 'none'; }); |
| } else if (opt.dataset.jdmBlown === '1') { |
| // Reset (cas où l'état a basculé blown -> dispo) |
| opt.style.opacity = ''; |
| opt.style.color = ''; |
| delete opt.dataset.jdmBlown; |
| if (existing) existing.remove(); |
| var ticks2 = opt.querySelectorAll('svg.checkmark, span.checkmark, .check-icon'); |
| ticks2.forEach(function(ic) { ic.style.display = ''; }); |
| } |
| |
| // BYOK : remplacer le ✓ par 🔑 pour les options qui contiennent |
| // « BYOK » dans leur label (Claude, GPT). Même technique que ❌. |
| var isByok = !isBlown && text.indexOf('BYOK') !== -1; |
| var existingKey = opt.querySelector('.jdm-key-marker'); |
| if (isByok) { |
| opt.dataset.jdmByok = '1'; |
| if (!existingKey) { |
| var k = document.createElement('span'); |
| k.textContent = '🔑 '; |
| k.className = 'jdm-key-marker'; |
| k.style.marginRight = '4px'; |
| k.style.display = 'inline-block'; |
| opt.insertBefore(k, opt.firstChild); |
| } |
| var ticks3 = opt.querySelectorAll('svg.checkmark, span.checkmark, .check-icon'); |
| ticks3.forEach(function(ic) { ic.style.display = 'none'; }); |
| } else if (opt.dataset.jdmByok === '1' && !isByok) { |
| delete opt.dataset.jdmByok; |
| if (existingKey) existingKey.remove(); |
| } |
| }); |
| } |
| |
| // Injection d'un bouton stylé « Rotation clés gemini (clé X/N) » à |
| // la FIN de la liste d'options du dropdown modèle. On l'ajoute DANS |
| // le ul (sinon caché par overflow du conteneur Gradio). |
| // |
| // ASTUCE pour ne pas casser la reconciliation Gradio sur click : |
| // on RETIRE le bouton du DOM JUSTE AVANT de dispatch le click sur |
| // le bouton Gradio caché. Gradio diff son ul avec 7 enfants (ses |
| // 7 options), pas 8. Notre MutationObserver re-injecte le bouton |
| // après que Gradio ait fini sa MAJ. |
| function injectSwitchKeyButton() { |
| var hidden = document.getElementById('jarvis-switch-key-btn') |
| || document.getElementById('chat-switch-key-btn'); |
| if (!hidden) return; |
| var btnLabel = (hidden.textContent || '🔄 Rotation clés gemini').trim(); |
| |
| var lists = document.querySelectorAll('ul[role="listbox"]'); |
| lists.forEach(function(ul) { |
| var optsTxt = (ul.textContent || ''); |
| if (optsTxt.indexOf('Gemini') === -1) return; |
| var existing = ul.querySelector('.jdm-switch-key-injected'); |
| if (existing) { |
| // Update juste le label si nécessaire — pas de remove/recreate |
| if (existing.textContent !== btnLabel) existing.textContent = btnLabel; |
| return; |
| } |
| // Création UNIQUE : le bouton reste en place pendant les |
| // re-renders Svelte de ul, car {#each filtered_indices as index} |
| // ne diff que les <li data-index=...> qu'il a créés. Notre <li> |
| // sans data-index est invisible pour Svelte → jamais touché. |
| var btn = document.createElement('li'); |
| btn.className = 'jdm-switch-key-injected'; |
| btn.textContent = btnLabel; |
| btn.setAttribute('role', 'button'); |
| btn.addEventListener('click', function(ev) { |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| // PAS de removeChild ici — le bouton reste dans le DOM |
| // pendant toute la rotation. Comme Svelte ne le track pas |
| // dans son {#each}, il survit aux re-renders intermédiaires. |
| var liInput = null; |
| try { |
| liInput = ul.closest('.form, [class*="block"]') |
| ?.querySelector('input[role="listbox"]'); |
| } catch (e) {} |
| if (liInput) { |
| var setter = Object.getOwnPropertyDescriptor( |
| window.HTMLInputElement.prototype, 'value' |
| ).set; |
| var resetObs = new MutationObserver(function() { |
| var v = liInput.value; |
| if (v) { |
| liInput.placeholder = v; |
| liInput.classList.add('jdm-placeholder-as-value'); |
| setter.call(liInput, ''); |
| liInput.dispatchEvent(new Event('input', {bubbles: true})); |
| } |
| }); |
| resetObs.observe(ul, { childList: true }); |
| setTimeout(function() { resetObs.disconnect(); }, 2000); |
| } |
| hidden.click(); |
| }); |
| ul.appendChild(btn); |
| }); |
| } |
| |
| function applyTabTweaks() { |
| pushAideTabRight(); |
| colorJarvisTab(); |
| applyThinkingTooltip(); |
| replaceCheckOnBlownOptions(); |
| injectSwitchKeyButton(); |
| } |
| document.addEventListener('DOMContentLoaded', applyTabTweaks); |
| setTimeout(applyTabTweaks, 400); |
| setTimeout(applyTabTweaks, 1200); |
| setTimeout(applyTabTweaks, 2500); |
| // Si l'utilisateur clique sur un autre onglet et revient, Gradio peut |
| // re-render et perdre les tweaks — on observe le DOM. |
| new MutationObserver(function() { |
| applyTabTweaks(); |
| }).observe(document.body, { childList: true, subtree: true }); |
| })(); |
| </script> |
| """ |
|
|
| _CHATBOT_CSS = """ |
| /* NB : les overrides de fond clair (zinc grays au lieu de blanc pur) |
| sont passes au constructeur du THEME via .set() — l'API officielle |
| Gradio plutot que via CSS injection (qui etait scopee et inoperante). |
| Cf. definition de THEME dans le module. */ |
| |
| /* ----- Productions : CheckboxGroup uniforme (mm largeur + ellipsis). |
| Cible par elem_id pour ne pas affecter les autres CheckboxGroup. |
| Le label HTML est dans .wrap > label > span (Gradio v5). */ |
| #prod-file-radio label, #prod-oldies-radio label { |
| width: 100% !important; |
| max-width: 100% !important; |
| box-sizing: border-box !important; |
| margin-bottom: 4px !important; |
| } |
| #prod-file-radio label span, #prod-oldies-radio label span { |
| display: inline-block !important; |
| max-width: calc(100% - 28px) !important; |
| white-space: nowrap !important; |
| overflow: hidden !important; |
| text-overflow: ellipsis !important; |
| vertical-align: middle !important; |
| } |
| /* Files SOUMIS = label commence par ✅ (cf. _format_file_entry) → |
| teinte la bulle pour indiquer son etat. Attribute selector sur le |
| span enfant qui contient le texte. */ |
| #prod-file-radio label:has(span[title^="✅"]), |
| #prod-file-radio label:has(span:first-child + span:not([title])), |
| #prod-oldies-radio label:has(span[title^="✅"]) { |
| /* Fallback : on cible via le texte du span directement */ |
| } |
| #prod-file-radio label span:first-of-type, |
| #prod-oldies-radio label span:first-of-type { |
| /* Detect submitted via le texte commence par ✅ (le ✅ est dans le |
| premier caractere du label) — on l'utilise comme indice visuel. */ |
| } |
| /* Marquage soumis : on injecte une classe via JS si label commence |
| par ✅ (cf. _HEAD_JS). Quand la classe est presente, fond vert |
| discret. Sinon, fond neutre. */ |
| #prod-file-radio label.prod-submitted, |
| #prod-oldies-radio label.prod-submitted { |
| background: rgba(34,197,94,0.10) !important; |
| border-left: 3px solid rgba(34,197,94,0.6) !important; |
| padding-left: 6px !important; |
| } |
| |
| /* ----- Mise en evidence des termes-cles dans les narrations Jarvis. |
| Le helper `_hi()` dans jarvis.py wrap les termes/cibles dans des |
| <span class="jarvis-term">. Couleur ambre saturee + bold leger → |
| le terme accroche l'oeil sans crier. Si Gradio strippe la classe, |
| le texte reste lisible (juste non colore). |
| IMPORTANT : la regle suivante `.jdm-narration *` plus loin applique |
| `color: #82aaff !important` a TOUS les enfants avec specificite |
| (0,1,1) qui battrait `.jarvis-term` (0,1,0). Donc on utilise |
| `.jdm-narration .jarvis-term` (0,2,0) pour gagner. Idem pour les |
| relations stylees dans le bloc plus bas. */ |
| .jdm-narration .jarvis-term, |
| .jarvis-term { |
| color: #d97706 !important; /* ambre-600 — visible dark + light theme */ |
| font-weight: 600 !important; |
| } |
| |
| /* Relations JDM en backticks → markdown rend en <code>. Couleur |
| distincte (violet doux) pour creer une 2eme couche visuelle : |
| termes en orange, relations en violet, prose en bleu-cyan. |
| Specificite (0,1,1) = celle de `.jdm-narration *`, mais code est |
| un element + .jdm-narration une classe → (0,1,2) vs (0,1,1) → |
| gagne. */ |
| .jdm-narration code { |
| color: #c4b5fd !important; /* violet-300, lit dark + light */ |
| font-weight: 500 !important; |
| background: rgba(196,181,253,0.08) !important; |
| padding: 0 4px !important; |
| border-radius: 3px !important; |
| } |
| |
| /* ----- Gate admin : .admin-only cache par defaut, revele si l'element |
| recoit en plus la classe .admin-revealed (posee par le JS quand |
| ?admin=1 dans URL, cf. _HEAD_JS). |
| IMPORTANT : on EVITE TOUT selector d'ancetre (.admin-mode .admin-only, |
| body.admin-mode ...) parce que Gradio v5 scope le CSS injecte via |
| `css=` en prefixant chaque regle avec `gradio-container.gradio- |
| container-X.X.X .contain `. Du coup un selector base sur un ancetre |
| en dehors de .contain (le body par exemple) ne matche jamais apres |
| scoping. La double classe `.admin-only.admin-revealed` resout le |
| probleme : pas d'ancetre requis, le scoping de Gradio prefixe juste |
| les deux regles a l'identique, la specificite tranche. */ |
| .admin-only { display: none !important; } |
| .admin-only.admin-revealed { display: block !important; } |
| |
| /* Checkbox 'Raisonnement' flottante : position absolue dans le coin |
| haut-droit du conteneur de la Column qui contient le dropdown modèle |
| — alignée verticalement sur la baseline du label chip « Modèle ». |
| Le texte « Raisonnement » est à GAUCHE de la case (flex-direction |
| row-reverse sur le label de la checkbox). */ |
| .floating-thinking-wrap { |
| position: relative !important; |
| } |
| .floating-thinking-wrap .floating-thinking { |
| position: absolute !important; |
| /* Aligné verticalement sur le centre du chip « Modèle » du dropdown |
| en dessous. Le chip a un padding-top de ~12-16px dans le wrapper, |
| plus sa demi-hauteur (~10px). On vise ~20px depuis le haut du |
| conteneur de la Column. */ |
| top: 20px !important; |
| right: 8px !important; |
| /* Translate -50% pour centrer la case sur cette ligne (la case fait |
| ~20px de haut → on monte de la moitié pour que le CENTRE de la case |
| soit à top:20px). */ |
| transform: translateY(-50%) !important; |
| z-index: 5 !important; |
| min-width: 0 !important; |
| width: auto !important; |
| background: transparent !important; |
| border: none !important; |
| padding: 0 !important; |
| margin: 0 !important; |
| font-size: 1em !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| line-height: 1 !important; |
| } |
| /* Inverse l'ordre interne du label de la checkbox : case à droite, |
| texte « Raisonnement » à gauche. Sélecteurs multiples pour couvrir |
| les variantes de rendu Gradio v5. */ |
| .floating-thinking-wrap .floating-thinking label, |
| .floating-thinking-wrap .floating-thinking label > div, |
| .floating-thinking-wrap .floating-thinking [data-testid="checkbox"] { |
| flex-direction: row-reverse !important; |
| gap: 6px !important; |
| align-items: center !important; |
| white-space: nowrap !important; |
| } |
| |
| /* Chatbot agent : compact quand vide, grandit avec le contenu jusqu'à |
| ~85vh (presque la hauteur de la fenêtre). On force min-height à 0 sur |
| le wrapper et tous les enfants pour défaire la hauteur fixe par défaut |
| Gradio. Le JS dans _HEAD_JS surveille les changements de contenu et |
| ajuste dynamiquement la hauteur réelle. */ |
| #agent-chatbot, |
| #agent-chatbot > div, |
| #agent-chatbot .wrap, |
| #agent-chatbot .bubble-wrap, |
| #agent-chatbot .message-wrap, |
| #agent-chatbot [class*="bubble-wrap"], |
| #agent-chatbot [class*="message-wrap"] { |
| min-height: 0 !important; |
| max-height: none !important; |
| } |
| #agent-chatbot { height: auto !important; } |
| #agent-chatbot .bubble-wrap, |
| #agent-chatbot [class*="bubble-wrap"] { |
| height: auto !important; |
| overflow-y: auto; |
| } |
| |
| /* Blocs « thinking » dans les messages Jarvis — rendus discrets : |
| très petits, grisés, italiques. La classe `.jdm-thinking` est |
| appliquée par un <div> dans les progress_lines de jarvis.py. |
| On passe par une classe car les attributs `style=` inline sont |
| filtrés par DOMPurify de Gradio v5. */ |
| .jdm-thinking, |
| .jdm-thinking * { |
| font-size: 0.85em !important; |
| color: #999 !important; |
| font-style: italic !important; |
| line-height: 1.35 !important; |
| } |
| |
| /* Narrations des outils Jarvis (« 📖 Je vérifie l'existence… », |
| « 🔧 enrichment_workflow(...) », « ✓ outil renvoie N chars »). |
| MÊME taille et italique que thinking, mais teinte BLEU-CYAN doux |
| (#82aaff) au lieu du gris — se démarque subtilement du flux |
| principal blanc tout en restant lisible. Distinct du violet |
| primaire de Gradio (accents UI) pour éviter la confusion. */ |
| .jdm-narration, |
| .jdm-narration * { |
| font-size: 0.85em !important; |
| color: #82aaff !important; |
| font-style: italic !important; |
| line-height: 1.35 !important; |
| } |
| |
| /* Responsive mobile : sur petits écrans (< 768px), les gr.Row de |
| Gradio v5 gardent leurs colonnes côte à côte avec leur scale, ce |
| qui donne des champs étriqués sur smartphone (cf. screenshot user). |
| On force ici l'empilement vertical : chaque enfant de Row prend la |
| pleine largeur. Couvre plusieurs noms de classes Gradio (varie |
| entre versions). */ |
| @media (max-width: 768px) { |
| .gradio-container .form, |
| .gradio-container .row, |
| .gradio-container [class*="row"]:not([class*="tab"]), |
| .gradio-container [class*="flex-row"] { |
| flex-direction: column !important; |
| flex-wrap: wrap !important; |
| } |
| .gradio-container .row > *, |
| .gradio-container [class*="row"]:not([class*="tab"]) > *, |
| .gradio-container [class*="flex-row"] > * { |
| width: 100% !important; |
| min-width: 0 !important; |
| flex: 1 1 100% !important; |
| } |
| } |
| |
| /* Champ clé API : quand le textbox est interactive=False (i.e. l'input |
| est disabled), on grise AUSSI le label et l'info de placeholder, pas |
| seulement le champ. CSS :has() supporté par tous les navigateurs |
| modernes (Chrome 105+, Firefox 121+, Safari 15.4+). */ |
| #key-in:has(input[disabled]), |
| #key-in:has(input:disabled) { |
| opacity: 0.55; |
| } |
| |
| /* Boutons « Changer de clé API » masqués (Gradio les rend mais on les |
| cache visuellement — leurs clicks restent triggerables via JS |
| depuis le bouton clone injecté dans le dropdown). */ |
| .jdm-hidden-switch-btn { |
| display: none !important; |
| } |
| |
| /* Style du bouton clone INJECTÉ par JS à la fin de la liste d'options |
| du dropdown modèle. Format = bouton standard, distinct des options. |
| `order: 999` + flex sur ul (règle suivante) → le bouton apparaît |
| TOUJOURS visuellement en dernier, quelle que soit sa position dans |
| le DOM (Svelte peut le faire remonter après un re-render, il reste |
| visuellement en bas). */ |
| .jdm-switch-key-injected { |
| display: block !important; |
| width: calc(100% - 16px) !important; |
| margin: 8px !important; |
| padding: 10px 16px !important; |
| background: var(--button-secondary-background-fill, #4b4b5c) !important; |
| border: 1px solid var(--button-secondary-border-color, #6b6b80) !important; |
| border-radius: 8px !important; |
| color: var(--button-secondary-text-color, #fff) !important; |
| font-weight: 600 !important; |
| text-align: center !important; |
| cursor: pointer !important; |
| user-select: none !important; |
| transition: background 0.15s ease !important; |
| order: 999 !important; |
| } |
| .jdm-switch-key-injected:hover { |
| background: var(--button-secondary-background-fill-hover, #5d5d70) !important; |
| } |
| |
| /* Active le layout flex column sur l'ul de listbox UNIQUEMENT s'il |
| contient notre bouton (donc le dropdown modèle). Ainsi le bouton |
| avec order:999 est toujours visuellement en bas, même si Svelte |
| réordonne les <li> du each block autour. */ |
| ul[role="listbox"]:has(.jdm-switch-key-injected) { |
| display: flex !important; |
| flex-direction: column !important; |
| } |
| |
| /* Placeholder utilisé comme valeur visible (post-rotation) — ressemble |
| à une vraie value pour que l'utilisateur ne voie pas un trigger vide. |
| Couleur body-text (au lieu du gris standard), opacité 1, pas |
| d'italique. Disparaît automatiquement dès que l'input.value devient |
| non-vide (next user-interaction). */ |
| input.jdm-placeholder-as-value::placeholder { |
| color: var(--body-text-color, #e0e0e0) !important; |
| opacity: 1 !important; |
| font-style: normal !important; |
| } |
| |
| /* Gradio v5 cache le ✓ sur les options non sélectionnées via |
| .inner-item.hide { visibility: hidden }. On le rend VISIBLE en gris |
| pour que chaque option ait son check (gris si non sélectionné, |
| normal si sélectionné). Désactivé sur options grisées blown |
| (data-jdm-blown=1) où on a déjà mis ❌. */ |
| ul[role="listbox"] li .inner-item.hide { |
| visibility: visible !important; |
| opacity: 0.28 !important; |
| } |
| ul[role="listbox"] li[data-jdm-blown="1"] .inner-item, |
| ul[role="listbox"] li[data-jdm-byok="1"] .inner-item { |
| visibility: hidden !important; |
| } |
| |
| /* Onglet Aide flush à droite — appliqué par JS (pushAideTabRight |
| dans _HEAD_JS) qui cherche par texte « Aide » dans tous les |
| boutons role="tab", car la structure DOM Gradio v5 varie. CSS |
| ci-dessous = fallback si le JS échoue. */ |
| [role="tablist"]:first-of-type > [role="tab"]:last-child { |
| margin-left: auto !important; |
| } |
| |
| /* Pleine largeur pour TOUS les onglets : Gradio v5 fixe un max-width |
| par défaut sur .gradio-container, ce qui laisse des bandes vides à |
| gauche/droite sur grands écrans. On le retire — fill_width=True sur |
| gr.Blocks suffit pour la grille de composants mais ne touche pas |
| au conteneur racine. */ |
| .gradio-container, .gradio-container.app { |
| max-width: 100% !important; |
| width: 100% !important; |
| } |
| """ |
|
|
| with gr.Blocks(theme=THEME, title="JDMAgent Demo", head=_HEAD_JS, css=_CHATBOT_CSS, |
| fill_width=True) as demo: |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| model_in = gr.Dropdown( |
| choices=build_model_choices(), |
| value="gemini-3.1-flash-lite", |
| label="Modèle", |
| filterable=False, |
| render=False, |
| ) |
| jarvis_model = gr.Dropdown( |
| choices=build_model_choices(for_jarvis=True), |
| value="gemini-3.1-flash-lite", |
| label="Modèle", |
| filterable=False, |
| render=False, |
| ) |
|
|
| with gr.Tabs() as main_tabs: |
|
|
| |
| with gr.Tab("📋 Projet"): |
| gr.Markdown(PROJET_MD) |
|
|
| |
| with gr.Tab("🔎 Explorer JDM"): |
| with gr.Row(): |
| term_in = gr.Textbox(label="Terme", value="chat", |
| placeholder="ex: voiture, chat, manger…") |
| rel_in = gr.Dropdown(list(EXPLORE_RELATIONS.keys()), |
| value="Hyperonymes — 'est un' (r_isa)", |
| label="Relation à explorer") |
| with gr.Row(): |
| mw_in = gr.Slider(0, 1000, value=25, step=5, label="Poids min (w ≥)") |
| lim_in = gr.Slider(5, 100, value=20, step=5, label="Limite de résultats") |
| annot_in = gr.Checkbox(value=True, label="Inclure les annotations") |
| explore_btn = gr.Button("Explorer", variant="primary") |
| explore_status = gr.Markdown() |
| explore_df = gr.Dataframe( |
| label="Triplets trouvés", |
| headers=["source", "relation", "target", "w", "annotations", "target_id (si raffinement)"], |
| interactive=False, |
| ) |
| explore_btn.click(explore, |
| inputs=[term_in, rel_in, mw_in, lim_in, annot_in], |
| outputs=[explore_df, explore_status]) |
|
|
| gr.Markdown("---\n### Désambiguïsation des termes polysémiques") |
| with gr.Row(): |
| dis_in = gr.Textbox(label="Terme polysémique", value="avocat", |
| placeholder="ex: avocat, souris, police, chat…") |
| dis_btn = gr.Button("Désambiguïser", variant="secondary") |
| dis_status = gr.Markdown() |
| dis_df = gr.Dataframe(label="Sens trouvés", |
| headers=["sens (décodé)", "poids", "id JDM"], |
| interactive=False) |
| dis_btn.click(disambiguate_term, inputs=[dis_in], |
| outputs=[dis_df, dis_status]) |
|
|
| |
| with gr.Tab("⚖️ Claim checker"): |
| with gr.Row(): |
| fc_subject = gr.Textbox(label="Sujet", value="baleine", |
| placeholder="ex: baleine, sang, voiture…") |
| fc_relation = gr.Dropdown(CLAIM_RELATIONS, value="r_isa", label="Relation") |
| fc_object = gr.Textbox(label="Objet / Cible", value="poisson", |
| placeholder="ex: poisson, rouge, roue…") |
| fc_effort = gr.Radio( |
| choices=list(EFFORT_CHOICES.keys()), |
| value="0 — Contenance (JDM contient-il ?)", |
| label="Régime de vérification", |
| info="Contenance = JDM contient-il littéralement le triplet ? " |
| "Inférence = peut-on le déduire du réseau si JDM est silencieux ?", |
| ) |
| fc_bypass = gr.Checkbox( |
| value=False, |
| label="Forcer l'inférence même si le triplet est déjà dans JDM", |
| info="Bypass de la contenance : montre la chaîne de déduction " |
| "d'un fait pourtant déjà connu (effort ≥ 1 requis).", |
| ) |
| fc_btn = gr.Button("Vérifier", variant="primary") |
| fc_status = gr.Markdown() |
| fc_evidence = gr.Markdown() |
| fc_btn.click(factcheck_one, |
| inputs=[fc_subject, fc_relation, fc_object, fc_effort, fc_bypass], |
| outputs=[fc_status, fc_evidence]) |
| gr.Examples( |
| examples=[ |
| ["baleine", "r_isa", "poisson", "0 — Contenance (JDM contient-il ?)"], |
| ["chat", "r_isa", "mammifère", "0 — Contenance (JDM contient-il ?)"], |
| ["sang", "r_has_color", "rouge", "0 — Contenance (JDM contient-il ?)"], |
| ["baleine", "r_isa", "poisson", "1 — + inférence (noyau)"], |
| ["couteau", "r_telic_role", "couper", "1 — + inférence (noyau)"], |
| ["saumon", "r_isa", "mammifère", "1 — + inférence (noyau)"], |
| ], |
| inputs=[fc_subject, fc_relation, fc_object, fc_effort], |
| ) |
|
|
| |
| with gr.Tab("🕸️ Sous-graphe"): |
| with gr.Row(): |
| viz_term = gr.Textbox(label="Terme racine", value="plat asiatique", |
| placeholder="ex: chat, polyphonie, voiture…", |
| scale=3) |
| viz_depth = gr.Slider(1, 4, value=1, step=1, label="Profondeur", |
| scale=1) |
| |
| |
| |
| _ALL_REL_CHOICES = DEFAULT_RELATIONS + [ |
| r for r in ("r_syn", "r_anto", "r_patient-1", "r_agent-1", "r_associated") |
| if r not in DEFAULT_RELATIONS |
| ] |
| _DEFAULT_TOPK = 3 |
| |
| |
| |
| with gr.Accordion("⚙️ Réglages par niveau (top-K + relations)", |
| open=False): |
| |
| with gr.Row(): |
| viz_topk = gr.Slider(1, 15, value=_DEFAULT_TOPK, step=1, |
| label="Top-K niveau 1") |
| viz_relations = gr.CheckboxGroup( |
| choices=_ALL_REL_CHOICES, |
| value=DEFAULT_RELATIONS, |
| label="Niveau 1 — voisins directs du terme", |
| ) |
| |
| with gr.Group(visible=False) as viz_level2_group: |
| with gr.Row(): |
| viz_topk_d2 = gr.Slider(1, 15, value=_DEFAULT_TOPK, step=1, |
| label="Top-K niveau 2") |
| viz_depth2_relations = gr.CheckboxGroup( |
| choices=_ALL_REL_CHOICES, |
| value=DEFAULT_DEPTH2_RELATIONS, |
| label="Niveau 2 — voisins de voisins", |
| ) |
| |
| with gr.Group(visible=False) as viz_level3_group: |
| with gr.Row(): |
| viz_topk_d3 = gr.Slider(1, 15, value=_DEFAULT_TOPK, step=1, |
| label="Top-K niveau 3") |
| viz_depth3_relations = gr.CheckboxGroup( |
| choices=_ALL_REL_CHOICES, |
| value=DEFAULT_DEPTH3_RELATIONS, |
| label="Niveau 3", |
| ) |
| |
| with gr.Group(visible=False) as viz_level4_group: |
| with gr.Row(): |
| viz_topk_d4 = gr.Slider(1, 15, value=_DEFAULT_TOPK, step=1, |
| label="Top-K niveau 4") |
| viz_depth4_relations = gr.CheckboxGroup( |
| choices=_ALL_REL_CHOICES, |
| value=DEFAULT_DEPTH4_RELATIONS, |
| label="Niveau 4 (déconseillé sauf cas ciblé)", |
| ) |
|
|
| |
| def _update_levels_visibility(d): |
| d = int(d) |
| return ( |
| gr.update(visible=d >= 2), |
| gr.update(visible=d >= 3), |
| gr.update(visible=d >= 4), |
| ) |
|
|
| viz_depth.change( |
| _update_levels_visibility, |
| inputs=[viz_depth], |
| outputs=[viz_level2_group, viz_level3_group, viz_level4_group], |
| ) |
| viz_btn = gr.Button("Construire le sous-graphe", variant="primary") |
| viz_status = gr.Markdown() |
| viz_file = gr.File(label="Télécharger le HTML interactif", |
| interactive=False) |
| |
| viz_out = gr.HTML(label="Visualisation (inline)", elem_id="viz-output") |
| |
| |
| |
| _scroll_js = ( |
| "() => { setTimeout(() => { " |
| "const el = document.getElementById('viz-output'); " |
| "if (el) el.scrollIntoView({behavior:'smooth', block:'start'}); " |
| "}, 100); }" |
| ) |
| viz_btn.click( |
| viz_subgraph, |
| inputs=[viz_term, viz_depth, |
| viz_topk, viz_topk_d2, viz_topk_d3, viz_topk_d4, |
| viz_relations, viz_depth2_relations, |
| viz_depth3_relations, viz_depth4_relations], |
| outputs=[viz_status, viz_out, viz_file], |
| ).then(fn=None, inputs=None, outputs=None, js=_scroll_js) |
|
|
| |
| with gr.Tab("💬 LLM Chatbot"): |
| |
| |
| |
| with gr.Row(): |
| |
| |
| |
| key_in = gr.Textbox( |
| label="Clé API", |
| type="password", |
| placeholder="Non requis pour les modèles Gemini hébergés", |
| interactive=False, |
| elem_id="key-in", |
| scale=3, |
| ) |
| with gr.Column(scale=2, min_width=0, |
| elem_classes=["floating-thinking-wrap"]): |
| chat_thinking = gr.Checkbox( |
| value=False, |
| label="Raisonnement", |
| elem_id="chat-thinking-cb", |
| elem_classes=["floating-thinking"], |
| ) |
| model_in.render() |
| |
| |
| chat_switch_key_btn = gr.Button( |
| value=_switch_key_btn_label(), |
| size="sm", |
| elem_id="chat-switch-key-btn", |
| elem_classes=["jdm-hidden-switch-btn"], |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| viz_html_out = gr.HTML( |
| label="🕸️ Visualisation interactive du sous-graphe", |
| visible=False, |
| render=False, |
| ) |
| |
| |
| |
| |
| chat = gr.ChatInterface( |
| fn=chat_with_agent, |
| additional_inputs=[key_in, model_in, chat_thinking], |
| additional_outputs=[viz_html_out], |
| |
| |
| |
| |
| chatbot=gr.Chatbot( |
| |
| |
| |
| |
| |
| elem_id="agent-chatbot", |
| resizable=True, |
| type="messages", |
| show_label=False, |
| avatar_images=(None, None), |
| ), |
| |
| |
| |
| examples=[ |
| |
| |
| |
| |
| ["Quels sont les synonymes de voiture ?", "", "gemini-3.1-flash-lite", False], |
| ["Le saumon est-il un mammifère selon JDM ?", "", "gemini-3.1-flash-lite", False], |
| ["Pour le sens juridique de 'avocat', donne-moi 5 synonymes.", "", "gemini-3.1-flash-lite", False], |
| ["Que peut faire un chat ?", "", "gemini-3.1-flash-lite", False], |
| ["Quelles sont les composantes typiques d'un smartphone ?", "", "gemini-3.1-flash-lite", False], |
| ], |
| cache_examples=False, |
| type="messages", |
| ) |
| |
| |
| viz_html_out.render() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| viz_html_out.change( |
| fn=None, inputs=None, outputs=None, |
| js="""() => { |
| const el = document.getElementById('viz-container'); |
| if (!el) return; |
| // Annule un scroll en attente d'une precedente generation |
| if (window.__vizScrollPending) { |
| clearTimeout(window.__vizScrollPending); |
| window.__vizScrollPending = null; |
| } |
| // Si l'utilisateur scrolle / tape / clique pendant le |
| // delai, on abandonne le scroll auto (respect intent). |
| let userInteracted = false; |
| const cancel = () => { userInteracted = true; }; |
| const opts = { passive: true, once: true }; |
| window.addEventListener('wheel', cancel, opts); |
| window.addEventListener('touchmove', cancel, opts); |
| window.addEventListener('keydown', cancel, { once: true }); |
| window.addEventListener('mousedown', cancel, opts); |
| window.__vizScrollPending = setTimeout(() => { |
| if (!userInteracted) { |
| el.scrollIntoView({behavior: 'smooth', block: 'center'}); |
| } |
| // Cleanup listeners (les `once:true` se cleanup auto |
| // s'ils ont tire, mais s'ils n'ont pas tire on retire) |
| window.removeEventListener('wheel', cancel); |
| window.removeEventListener('touchmove', cancel); |
| window.removeEventListener('keydown', cancel); |
| window.removeEventListener('mousedown', cancel); |
| window.__vizScrollPending = null; |
| }, 700); |
| }""" |
| ) |
|
|
| |
| |
| |
| |
| chat.chatbot.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| |
| with gr.Tab("🤖 Agent Jarvis"): |
| gr.Markdown( |
| "# 🦾 Jarvis — flows guidés JDM\n\n" |
| "Pas de prompt à taper : remplis le formulaire, " |
| "Jarvis exécute le flux canonique correspondant. Tous " |
| "les fichiers produits (.enrich / .audit / .err) restent " |
| "en local sauf demande explicite d'upload LLMDrops." |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| with gr.Row(): |
| jarvis_drops_key = gr.Textbox( |
| label="Clé LLMDrops", |
| type="password", |
| placeholder="Optionnel — laisse vide pour utiliser la clé d'environnement", |
| elem_id="jarvis-drops-key", |
| scale=3, |
| ) |
| with gr.Column(scale=2, min_width=0, |
| elem_classes=["floating-thinking-wrap"]): |
| jarvis_thinking = gr.Checkbox( |
| value=False, |
| label="Raisonnement", |
| elem_id="jarvis-thinking-cb", |
| elem_classes=["floating-thinking"], |
| ) |
| jarvis_model.render() |
| jarvis_switch_key_btn = gr.Button( |
| value=_switch_key_btn_label(), |
| size="sm", |
| elem_id="jarvis-switch-key-btn", |
| elem_classes=["jdm-hidden-switch-btn"], |
| ) |
| jarvis_budget = gr.Dropdown( |
| choices=["10", "25", "50", "100", "illimité"], |
| value="illimité", |
| label="Budget", |
| scale=1, |
| ) |
|
|
| |
| |
| |
| |
| |
| jarvis_auto_switch_cb = gr.Checkbox( |
| value=False, |
| label="Auto-bascule sur 3.1 si quota épuisé (sinon : bouton « Continuer »)", |
| elem_id="jarvis-auto-switch-cb", |
| ) |
|
|
| |
| with gr.Tabs() as jarvis_tabs: |
|
|
| |
| with gr.Tab("🌱 Enrichissement", id="jarvis-enrich"): |
| gr.Markdown( |
| "Propose et consolide de nouveaux triplets pour un " |
| "terme. Le LLM suit `enrichment_workflow()` : " |
| "pré-fetch exhaustif → désambiguïsation → " |
| "proposition → validation+consolidation par " |
| "inférence → écriture du fichier `.enrich`." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| je_term = gr.Textbox( |
| label="Terme à enrichir (optionnel — vide = tirage au hasard)", |
| placeholder="ex: guitare", |
| ) |
| je_relation = gr.Dropdown( |
| choices=JARVIS_RELATIONS, |
| value=[], |
| label="Relation(s) cible(s) (optionnel — multi-sélection)", |
| allow_custom_value=True, |
| multiselect=True, |
| ) |
| je_target_n = gr.Slider( |
| 1, 50, value=3, step=1, |
| label="Nombre cible de triplets consolidés", |
| ) |
| je_vary = gr.Checkbox( |
| value=True, |
| label="Varier les types de relations", |
| ) |
| je_iterate = gr.Checkbox( |
| value=True, |
| label="Itérer jusqu'à atteindre le nombre cible", |
| ) |
| je_upload = gr.Checkbox( |
| value=False, |
| label="Soumettre directement à JDM (LLMDrops)", |
| info="Nécessite une clé API LLMDrops dans le bandeau ou en env JDM_DROPS_API_KEY.", |
| ) |
| je_launch = gr.Button( |
| "🌱 Lancer l'enrichissement", |
| variant="primary", |
| ) |
| |
| |
| |
| |
| from jarvis import has_drops_key as _has_dk |
| je_submit = gr.Button( |
| "📤 Soumettre à JDM (post-hoc)", |
| variant="stop", |
| visible=False, |
| interactive=_has_dk(), |
| ) |
| with gr.Column(scale=2): |
| je_chat = gr.Chatbot( |
| type="messages", |
| elem_id="jarvis-enrich-chat", |
| show_label=False, |
| resizable=True, |
| height=520, |
| ) |
| je_file = gr.File( |
| label="📄 Fichier produit (télécharger)", |
| interactive=False, |
| visible=False, |
| ) |
| je_preview = gr.Code( |
| label="Aperçu du fichier", |
| language=None, |
| lines=12, |
| interactive=False, |
| visible=False, |
| ) |
| |
| |
| |
| je_resume_state = gr.State(value=None) |
| je_continue_btn = gr.Button( |
| "▶️ Continuer avec gemini-3.1-flash-lite", |
| visible=False, |
| variant="primary", |
| ) |
|
|
| def _run_enrich(term, relations, target_n, vary, iterate, upload, |
| drops_key, model, budget_label, use_thinking, |
| auto_switch, resume_state): |
| """Wrapper Gradio : construit le prompt, lance le flow. |
| Yield 5-tuples (chat, file, preview, state, btn_update). |
| Si resume_state n'est pas None, run_jarvis_flow reprend |
| sur l'état sauvé (mode B continue).""" |
| from jarvis import build_enrich_prompt, run_jarvis_flow |
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
| prompt = build_enrich_prompt( |
| term=term, |
| relation=relations, |
| target_count=int(target_n), |
| vary_relations=bool(vary), |
| iterate=bool(iterate), |
| budget_label=str(budget_label), |
| upload=bool(upload), |
| ) |
| t = (term or "").strip() |
| headline = ( |
| f"🌱 Enrichissement de « {t} »" |
| if t else "🌱 Enrichissement (terme tiré au hasard)" |
| ) |
| abort_yielded = False |
| for chunk in run_jarvis_flow( |
| prompt=prompt, |
| headline=headline, |
| model=model, |
| api_key="", |
| budget_label=str(budget_label), |
| drops_key=drops_key, |
| build_llm_fn=_build_llm, |
| build_agent_fn=build_jdm_agent, |
| get_client_fn=get_client, |
| use_thinking=bool(use_thinking), |
| consolidation_target=( |
| int(target_n) if bool(iterate) else None |
| ), |
| auto_switch_on_perday=bool(auto_switch), |
| resume_state=resume_state, |
| ): |
| if len(chunk) == 5: |
| |
| messages, fpath, fprev, state, _ = chunk |
| abort_yielded = True |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| state, |
| gr.update(visible=True), |
| ) |
| else: |
| messages, fpath, fprev = chunk |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| gr.skip(), |
| gr.skip(), |
| ) |
| |
| |
| |
| if not abort_yielded: |
| yield (gr.skip(), gr.skip(), gr.skip(), |
| None, gr.update(visible=False)) |
|
|
| _je_inputs = [ |
| je_term, je_relation, je_target_n, je_vary, |
| je_iterate, je_upload, |
| jarvis_drops_key, jarvis_model, jarvis_budget, |
| jarvis_thinking, jarvis_auto_switch_cb, je_resume_state, |
| ] |
| _je_outputs = [je_chat, je_file, je_preview, |
| je_resume_state, je_continue_btn] |
| je_launch.click(_run_enrich, inputs=_je_inputs, outputs=_je_outputs) |
| je_continue_btn.click(_run_enrich, inputs=_je_inputs, outputs=_je_outputs) |
| je_chat.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| |
| |
| |
| |
| def _toggle_vary_state(rels): |
| has_rels = bool(rels) |
| return gr.update(interactive=not has_rels) |
|
|
| je_relation.change( |
| _toggle_vary_state, |
| inputs=[je_relation], |
| outputs=[je_vary], |
| ) |
|
|
| |
| |
| def _show_submit_btn(file_path, drops_key): |
| if not file_path: |
| return gr.update(visible=False) |
| from jarvis import has_drops_key as _hk |
| return gr.update(visible=True, interactive=_hk(drops_key)) |
|
|
| je_file.change( |
| _show_submit_btn, |
| inputs=[je_file, jarvis_drops_key], |
| outputs=[je_submit], |
| ) |
|
|
| def _submit_enrich(file_path, drops_key, model, chat): |
| from jarvis import submit_existing_file |
| return submit_existing_file(file_path, drops_key, model, chat) |
|
|
| je_submit.click( |
| _submit_enrich, |
| inputs=[je_file, jarvis_drops_key, jarvis_model, je_chat], |
| outputs=[je_chat], |
| ) |
|
|
| with gr.Tab("🔍 Audit", id="jarvis-audit"): |
| gr.Markdown( |
| "Audit sémantique : détecte les CONTAMINATIONS du " |
| "terme générique par les relations propres aux sens " |
| "NON-PREMIERS. Produit un fichier `.audit` factuel " |
| "(SENS + SIGNALEMENTS + META score/10)." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| ja_term = gr.Textbox( |
| label="Terme à auditer (optionnel — vide = tirage polysémique au hasard)", |
| placeholder="ex: avocat", |
| ) |
| ja_relation = gr.Dropdown( |
| choices=JARVIS_RELATIONS, |
| value=[], |
| label="Relation(s) à auditer (optionnel — multi-sélection)", |
| allow_custom_value=True, |
| multiselect=True, |
| info="Vide = le LLM choisit (couvre plusieurs types).", |
| ) |
| ja_upload = gr.Checkbox( |
| value=False, |
| label="Soumettre directement à JDM (LLMDrops)", |
| info="Nécessite une clé API LLMDrops dans le bandeau ou en env.", |
| ) |
| ja_launch = gr.Button( |
| "🔍 Lancer l'audit", |
| variant="primary", |
| ) |
| from jarvis import has_drops_key as _hk_a |
| ja_submit = gr.Button( |
| "📤 Soumettre à JDM (post-hoc)", |
| variant="stop", |
| visible=False, |
| interactive=_hk_a(), |
| ) |
| with gr.Column(scale=2): |
| ja_chat = gr.Chatbot( |
| type="messages", |
| elem_id="jarvis-audit-chat", |
| show_label=False, |
| resizable=True, |
| height=520, |
| ) |
| ja_file = gr.File( |
| label="📄 Fichier produit (télécharger)", |
| interactive=False, |
| visible=False, |
| ) |
| ja_preview = gr.Code( |
| label="Aperçu du fichier", |
| language=None, |
| lines=14, |
| interactive=False, |
| visible=False, |
| ) |
| ja_resume_state = gr.State(value=None) |
| ja_continue_btn = gr.Button( |
| "▶️ Continuer avec gemini-3.1-flash-lite", |
| visible=False, variant="primary", |
| ) |
|
|
| def _run_audit(term, relations, upload, drops_key, model, budget_label, |
| use_thinking, auto_switch, resume_state): |
| from jarvis import build_audit_prompt, run_jarvis_flow |
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
| prompt = build_audit_prompt( |
| term=term, relation=relations, |
| budget_label=str(budget_label), |
| upload=bool(upload), |
| ) |
| t = (term or "").strip() |
| headline = ( |
| f"🔍 Audit de « {t} »" |
| if t else "🔍 Audit (terme polysémique tiré au hasard)" |
| ) |
| abort_yielded = False |
| for chunk in run_jarvis_flow( |
| prompt=prompt, headline=headline, |
| model=model, api_key="", |
| budget_label=str(budget_label), |
| drops_key=drops_key, |
| build_llm_fn=_build_llm, |
| build_agent_fn=build_jdm_agent, |
| get_client_fn=get_client, |
| use_thinking=bool(use_thinking), |
| auto_switch_on_perday=bool(auto_switch), |
| resume_state=resume_state, |
| ): |
| if len(chunk) == 5: |
| messages, fpath, fprev, state, _ = chunk |
| abort_yielded = True |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| state, gr.update(visible=True), |
| ) |
| else: |
| messages, fpath, fprev = chunk |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| gr.skip(), gr.skip(), |
| ) |
| if not abort_yielded: |
| yield (gr.skip(), gr.skip(), gr.skip(), |
| None, gr.update(visible=False)) |
|
|
| _ja_inputs = [ja_term, ja_relation, ja_upload, |
| jarvis_drops_key, jarvis_model, jarvis_budget, |
| jarvis_thinking, jarvis_auto_switch_cb, |
| ja_resume_state] |
| _ja_outputs = [ja_chat, ja_file, ja_preview, |
| ja_resume_state, ja_continue_btn] |
| ja_launch.click(_run_audit, inputs=_ja_inputs, outputs=_ja_outputs) |
| ja_continue_btn.click(_run_audit, inputs=_ja_inputs, outputs=_ja_outputs) |
| ja_chat.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| def _show_audit_submit(file_path, drops_key): |
| if not file_path: |
| return gr.update(visible=False) |
| from jarvis import has_drops_key as _hk |
| return gr.update(visible=True, interactive=_hk(drops_key)) |
|
|
| ja_file.change( |
| _show_audit_submit, |
| inputs=[ja_file, jarvis_drops_key], |
| outputs=[ja_submit], |
| ) |
|
|
| def _submit_audit(file_path, drops_key, model, chat): |
| from jarvis import submit_existing_file |
| return submit_existing_file(file_path, drops_key, model, chat) |
|
|
| ja_submit.click( |
| _submit_audit, |
| inputs=[ja_file, jarvis_drops_key, jarvis_model, ja_chat], |
| outputs=[ja_chat], |
| ) |
|
|
| with gr.Tab("🕳️ Détection de trous", id="jarvis-gaps"): |
| gr.Markdown( |
| "Identifie les trous de couverture (MISSING / " |
| "NEGATIVE_FILLED / LOW_COVERAGE) pour un terme. " |
| "Le LLM ajoute une synthèse narrative et propose " |
| "des routages vers les autres sous-onglets." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| jg_term = gr.Textbox( |
| label="Terme à analyser (optionnel — vide = tirage au hasard)", |
| placeholder="ex: smartphone", |
| ) |
| jg_relations = gr.Dropdown( |
| choices=JARVIS_RELATIONS, |
| value=[], |
| label="Relation(s) à examiner (optionnel — multi-sélection)", |
| allow_custom_value=True, |
| multiselect=True, |
| ) |
| jg_min_pos = gr.Slider( |
| 1, 10, value=3, step=1, |
| label="Seuil LOW_COVERAGE (< N triplets positifs)", |
| ) |
| jg_launch = gr.Button( |
| "🕳️ Détecter les trous", |
| variant="primary", |
| ) |
| gr.Markdown("### Router un trou détecté") |
| jg_gap_dropdown = gr.Dropdown( |
| choices=[], |
| label="Choisis un gap à router", |
| interactive=True, |
| ) |
| with gr.Row(): |
| jg_route_enrich = gr.Button("→ Enrichir ce trou", scale=1) |
| jg_route_audit = gr.Button("→ Auditer", scale=1) |
| jg_route_stats = gr.Button("→ Stats", scale=1) |
| with gr.Column(scale=2): |
| jg_gaps_table = gr.Dataframe( |
| headers=["term", "relation", "type", "sévérité", "détail"], |
| datatype=["str", "str", "str", "number", "str"], |
| label="Trous détectés (déterministe, instantané)", |
| interactive=False, |
| wrap=True, |
| ) |
| jg_chat = gr.Chatbot( |
| type="messages", |
| elem_id="jarvis-gaps-chat", |
| label="Synthèse de l'agent", |
| show_label=True, |
| resizable=True, |
| height=400, |
| ) |
| jg_resume_state = gr.State(value=None) |
| jg_continue_btn = gr.Button( |
| "▶️ Continuer avec gemini-3.1-flash-lite", |
| visible=False, variant="primary", |
| ) |
|
|
| def _run_gap_detection(term, relations, min_pos, drops_key, model, |
| budget_label, use_thinking, |
| auto_switch, resume_state): |
| """Détecte les gaps DIRECTEMENT (rapide, déterministe) |
| puis lance l'agent pour la synthèse narrative.""" |
| from jarvis import build_gap_prompt, run_jarvis_flow |
| from jdm_agent.enrich.detectors import detect_gaps |
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
|
|
| term = (term or "").strip() |
| target_rels = list(relations) if relations else None |
|
|
| |
| |
| |
| if term: |
| try: |
| gaps = detect_gaps( |
| get_client(), term, |
| target_relations=target_rels, |
| min_to_consider=int(min_pos), |
| ) |
| except Exception as e: |
| yield ( |
| gr.update(value=[]), |
| gr.update(choices=[], value=None), |
| [{"role": "assistant", |
| "content": f"❌ Erreur de détection : {e}"}], |
| ) |
| return |
|
|
| table_rows = [ |
| [g.term, g.relation, g.gap_type.value, |
| round(g.severity, 2), g.detail[:120]] |
| for g in gaps |
| ] |
| gap_labels = [ |
| f"{g.term} | {g.relation} | {g.gap_type.value}" |
| for g in gaps |
| ] |
| user_msg = f"Détecte les trous de « {term} »." |
| else: |
| |
| table_rows = [] |
| gap_labels = [] |
| user_msg = ( |
| "Détecte les trous de JDM pour un terme " |
| "(tiré au hasard par toi)." |
| ) |
|
|
| |
| |
| yield ( |
| gr.update(value=table_rows), |
| gr.update(choices=gap_labels, |
| value=gap_labels[0] if gap_labels else None), |
| [{"role": "user", "content": user_msg}, |
| {"role": "assistant", |
| "content": "*🧠 Synthèse en cours…*"}], |
| ) |
|
|
| |
| prompt = build_gap_prompt( |
| term=term, relations=target_rels, |
| budget_label=str(budget_label), |
| ) |
| headline = ( |
| f"🕳️ Détection de trous pour « {term} »" |
| if term else "🕳️ Détection (terme tiré au hasard)" |
| ) |
| |
| |
| |
| abort_yielded = False |
| for chunk in run_jarvis_flow( |
| prompt=prompt, headline=headline, |
| model=model, api_key="", |
| budget_label=str(budget_label), |
| drops_key=drops_key, |
| build_llm_fn=_build_llm, |
| build_agent_fn=build_jdm_agent, |
| get_client_fn=get_client, |
| use_thinking=bool(use_thinking), |
| auto_switch_on_perday=bool(auto_switch), |
| resume_state=resume_state, |
| ): |
| if len(chunk) == 5: |
| chat_msgs, _fp, _fpv, state, _ = chunk |
| abort_yielded = True |
| yield (gr.update(), gr.update(), chat_msgs, |
| state, gr.update(visible=True)) |
| else: |
| chat_msgs, _fp, _fpv = chunk |
| yield (gr.update(), gr.update(), chat_msgs, |
| gr.skip(), gr.skip()) |
| if not abort_yielded: |
| yield (gr.skip(), gr.skip(), gr.skip(), |
| None, gr.update(visible=False)) |
|
|
| _jg_inputs = [jg_term, jg_relations, jg_min_pos, |
| jarvis_drops_key, jarvis_model, jarvis_budget, |
| jarvis_thinking, jarvis_auto_switch_cb, |
| jg_resume_state] |
| _jg_outputs = [jg_gaps_table, jg_gap_dropdown, jg_chat, |
| jg_resume_state, jg_continue_btn] |
| jg_launch.click(_run_gap_detection, inputs=_jg_inputs, outputs=_jg_outputs) |
| jg_continue_btn.click(_run_gap_detection, inputs=_jg_inputs, outputs=_jg_outputs) |
| jg_chat.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| |
| |
| def _route_gap_to_enrich(gap_label): |
| if not gap_label: |
| return (gr.update(), gr.update(), |
| gr.update()) |
| parts = [p.strip() for p in gap_label.split("|")] |
| if len(parts) < 2: |
| return (gr.update(), gr.update(), gr.update()) |
| term_v, relation_v = parts[0], parts[1] |
| return ( |
| gr.update(value=term_v), |
| gr.update(value=[relation_v]), |
| gr.update(selected="jarvis-enrich"), |
| ) |
|
|
| jg_route_enrich.click( |
| _route_gap_to_enrich, |
| inputs=[jg_gap_dropdown], |
| outputs=[je_term, je_relation, jarvis_tabs], |
| ) |
|
|
| |
| |
| def _route_gap_to_audit(gap_label): |
| if not gap_label: |
| return (gr.update(), gr.update(), gr.update()) |
| parts = [p.strip() for p in gap_label.split("|")] |
| if len(parts) < 2: |
| return (gr.update(), gr.update(), gr.update()) |
| term_v, relation_v = parts[0], parts[1] |
| return ( |
| gr.update(value=term_v), |
| gr.update(value=[relation_v]), |
| gr.update(selected="jarvis-audit"), |
| ) |
| jg_route_audit.click( |
| _route_gap_to_audit, |
| inputs=[jg_gap_dropdown], |
| outputs=[ja_term, ja_relation, jarvis_tabs], |
| ) |
|
|
| with gr.Tab("⚠️ Signalement", id="jarvis-err"): |
| gr.Markdown( |
| "Scanne les triplets d'un terme (optionnellement " |
| "restreint à une ou plusieurs relations) et flagge " |
| "ceux qui paraissent suspects au LLM. Le LLM utilise " |
| "son **jugement linguistique** — sa suspicion vaut, " |
| "même sans preuve d'outil. Produit un fichier `.err`." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| js_term = gr.Textbox( |
| label="Terme à scanner (optionnel — vide = tirage au hasard)", |
| placeholder="ex: baleine", |
| ) |
| js_relation = gr.Dropdown( |
| choices=JARVIS_RELATIONS, |
| value=[], |
| label="Relation(s) à scanner (optionnel — multi-sélection)", |
| allow_custom_value=True, |
| multiselect=True, |
| ) |
| js_upload = gr.Checkbox( |
| value=False, |
| label="Soumettre directement à JDM (LLMDrops)", |
| info="Nécessite une clé API LLMDrops dans le bandeau ou en env.", |
| ) |
| js_launch = gr.Button( |
| "⚠️ Scanner et signaler", |
| variant="primary", |
| ) |
| from jarvis import has_drops_key as _hk_s |
| js_submit = gr.Button( |
| "📤 Soumettre à JDM (post-hoc)", |
| variant="stop", |
| visible=False, |
| interactive=_hk_s(), |
| ) |
| with gr.Column(scale=2): |
| js_chat = gr.Chatbot( |
| type="messages", |
| elem_id="jarvis-err-chat", |
| show_label=False, |
| resizable=True, |
| height=520, |
| ) |
| js_file = gr.File( |
| label="📄 Fichier produit (télécharger)", |
| interactive=False, |
| visible=False, |
| ) |
| js_preview = gr.Code( |
| label="Aperçu du fichier", |
| language=None, |
| lines=12, |
| interactive=False, |
| visible=False, |
| ) |
| js_resume_state = gr.State(value=None) |
| js_continue_btn = gr.Button( |
| "▶️ Continuer avec gemini-3.1-flash-lite", |
| visible=False, variant="primary", |
| ) |
|
|
| def _run_signalement(term, relations, upload, drops_key, model, |
| budget_label, use_thinking, |
| auto_switch, resume_state): |
| from jarvis import build_signalement_prompt, run_jarvis_flow |
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
| prompt = build_signalement_prompt( |
| term=term, relation=relations, |
| budget_label=str(budget_label), |
| upload=bool(upload), |
| ) |
| t = (term or "").strip() |
| headline = ( |
| f"⚠️ Signalement pour « {t} »" |
| if t else "⚠️ Signalement (terme tiré au hasard)" |
| ) |
| abort_yielded = False |
| for chunk in run_jarvis_flow( |
| prompt=prompt, headline=headline, |
| model=model, api_key="", |
| budget_label=str(budget_label), |
| drops_key=drops_key, |
| build_llm_fn=_build_llm, |
| build_agent_fn=build_jdm_agent, |
| get_client_fn=get_client, |
| use_thinking=bool(use_thinking), |
| auto_switch_on_perday=bool(auto_switch), |
| resume_state=resume_state, |
| ): |
| if len(chunk) == 5: |
| messages, fpath, fprev, state, _ = chunk |
| abort_yielded = True |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| state, gr.update(visible=True), |
| ) |
| else: |
| messages, fpath, fprev = chunk |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| gr.skip(), gr.skip(), |
| ) |
| if not abort_yielded: |
| yield (gr.skip(), gr.skip(), gr.skip(), |
| None, gr.update(visible=False)) |
|
|
| _js_inputs = [js_term, js_relation, js_upload, |
| jarvis_drops_key, jarvis_model, jarvis_budget, |
| jarvis_thinking, jarvis_auto_switch_cb, |
| js_resume_state] |
| _js_outputs = [js_chat, js_file, js_preview, |
| js_resume_state, js_continue_btn] |
| js_launch.click(_run_signalement, inputs=_js_inputs, outputs=_js_outputs) |
| js_continue_btn.click(_run_signalement, inputs=_js_inputs, outputs=_js_outputs) |
| js_chat.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| def _show_signal_submit(file_path, drops_key): |
| if not file_path: |
| return gr.update(visible=False) |
| from jarvis import has_drops_key as _hk |
| return gr.update(visible=True, interactive=_hk(drops_key)) |
|
|
| js_file.change( |
| _show_signal_submit, |
| inputs=[js_file, jarvis_drops_key], |
| outputs=[js_submit], |
| ) |
|
|
| def _submit_signal(file_path, drops_key, model, chat): |
| from jarvis import submit_existing_file |
| return submit_existing_file(file_path, drops_key, model, chat) |
|
|
| js_submit.click( |
| _submit_signal, |
| inputs=[js_file, jarvis_drops_key, jarvis_model, js_chat], |
| outputs=[js_chat], |
| ) |
|
|
| with gr.Tab("📊 Stats", id="jarvis-stats"): |
| gr.Markdown( |
| "Statistiques de couverture JDM. Deux modes " |
| "(combinables) :\n" |
| "* **PAR_TERME** — distribution des triplets sur " |
| "les relations principales pour un terme donné.\n" |
| "* **PAR_RELATION** — distribution typique d'une " |
| "relation sur des termes-pivots variés.\n\n" |
| "Le LLM rend un tableau machine-lisible + des " |
| "observations clés en prose." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| jst_term = gr.Textbox( |
| label="Terme (optionnel — mode PAR_TERME)", |
| placeholder="ex: chat", |
| ) |
| jst_relation = gr.Dropdown( |
| choices=JARVIS_RELATIONS, |
| value=[], |
| label="Relation(s) (optionnel — multi-sélection)", |
| allow_custom_value=True, |
| multiselect=True, |
| info=( |
| "Si renseigné, le scan est restreint " |
| "strictement à ces relations. Sinon, " |
| "le LLM choisit librement (couverture " |
| "≥ 8-12 types)." |
| ), |
| ) |
| gr.Markdown( |
| "<small><em>Si les deux champs sont vides, " |
| "le LLM choisira un terme au hasard.</em></small>" |
| ) |
| jst_upload = gr.Checkbox( |
| value=False, |
| label="Soumettre directement à JDM (LLMDrops)", |
| info="Nécessite une clé API LLMDrops dans le bandeau ou en env.", |
| ) |
| jst_launch = gr.Button( |
| "📊 Lancer les stats", |
| variant="primary", |
| ) |
| from jarvis import has_drops_key as _hk_st |
| jst_submit = gr.Button( |
| "📤 Soumettre à JDM (post-hoc)", |
| variant="stop", |
| visible=False, |
| interactive=_hk_st(), |
| ) |
| with gr.Column(scale=2): |
| jst_chat = gr.Chatbot( |
| type="messages", |
| elem_id="jarvis-stats-chat", |
| show_label=False, |
| resizable=True, |
| height=520, |
| ) |
| jst_file = gr.File( |
| label="📄 Fichier produit (télécharger)", |
| interactive=False, |
| visible=False, |
| ) |
| jst_preview = gr.Code( |
| label="Aperçu du fichier", |
| language=None, |
| lines=12, |
| interactive=False, |
| visible=False, |
| ) |
| jst_resume_state = gr.State(value=None) |
| jst_continue_btn = gr.Button( |
| "▶️ Continuer avec gemini-3.1-flash-lite", |
| visible=False, variant="primary", |
| ) |
|
|
| def _run_stats(term, relations, upload, drops_key, model, |
| budget_label, use_thinking, |
| auto_switch, resume_state): |
| from jarvis import build_stats_prompt, run_jarvis_flow |
| from jdm_agent.tools.jdm_agent import build_jdm_agent |
| prompt = build_stats_prompt( |
| term=term, relation=relations, |
| budget_label=str(budget_label), |
| upload=bool(upload), |
| ) |
| t = (term or "").strip() |
| rels = relations if isinstance(relations, list) else ( |
| [relations] if relations else [] |
| ) |
| if t and rels: |
| headline = f"📊 Stats sur « {t} » + {len(rels)} relation(s)" |
| elif t: |
| headline = f"📊 Stats sur « {t} »" |
| elif rels: |
| headline = f"📊 Stats sur {len(rels)} relation(s)" |
| else: |
| headline = "📊 Stats (terme tiré au hasard)" |
| abort_yielded = False |
| for chunk in run_jarvis_flow( |
| prompt=prompt, headline=headline, |
| model=model, api_key="", |
| budget_label=str(budget_label), |
| drops_key=drops_key, |
| build_llm_fn=_build_llm, |
| build_agent_fn=build_jdm_agent, |
| get_client_fn=get_client, |
| use_thinking=bool(use_thinking), |
| auto_switch_on_perday=bool(auto_switch), |
| resume_state=resume_state, |
| ): |
| if len(chunk) == 5: |
| messages, fpath, fprev, state, _ = chunk |
| abort_yielded = True |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| state, gr.update(visible=True), |
| ) |
| else: |
| messages, fpath, fprev = chunk |
| yield ( |
| messages, |
| gr.update(value=fpath, visible=bool(fpath)), |
| gr.update(value=fprev, visible=bool(fprev)), |
| gr.skip(), gr.skip(), |
| ) |
| if not abort_yielded: |
| yield (gr.skip(), gr.skip(), gr.skip(), |
| None, gr.update(visible=False)) |
|
|
| _jst_inputs = [jst_term, jst_relation, jst_upload, |
| jarvis_drops_key, jarvis_model, jarvis_budget, |
| jarvis_thinking, jarvis_auto_switch_cb, |
| jst_resume_state] |
| _jst_outputs = [jst_chat, jst_file, jst_preview, |
| jst_resume_state, jst_continue_btn] |
| jst_launch.click(_run_stats, inputs=_jst_inputs, outputs=_jst_outputs) |
| jst_continue_btn.click(_run_stats, inputs=_jst_inputs, outputs=_jst_outputs) |
| jst_chat.change( |
| refresh_dropdowns_silent, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| def _show_stats_submit(file_path, drops_key): |
| if not file_path: |
| return gr.update(visible=False) |
| from jarvis import has_drops_key as _hk |
| return gr.update(visible=True, interactive=_hk(drops_key)) |
|
|
| jst_file.change( |
| _show_stats_submit, |
| inputs=[jst_file, jarvis_drops_key], |
| outputs=[jst_submit], |
| ) |
|
|
| def _submit_stats(file_path, drops_key, model, chat): |
| from jarvis import submit_existing_file |
| return submit_existing_file(file_path, drops_key, model, chat) |
|
|
| jst_submit.click( |
| _submit_stats, |
| inputs=[jst_file, jarvis_drops_key, jarvis_model, jst_chat], |
| outputs=[jst_chat], |
| ) |
|
|
| |
| |
| |
| def _route_gap_to_stats(gap_label): |
| if not gap_label: |
| return (gr.update(), gr.update(), gr.update()) |
| parts = [p.strip() for p in gap_label.split("|")] |
| if len(parts) < 2: |
| return (gr.update(), gr.update(), gr.update()) |
| term_v, relation_v = parts[0], parts[1] |
| return ( |
| gr.update(value=term_v), |
| gr.update(value=[relation_v]), |
| gr.update(selected="jarvis-stats"), |
| ) |
| jg_route_stats.click( |
| _route_gap_to_stats, |
| inputs=[jg_gap_dropdown], |
| outputs=[jst_term, jst_relation, jarvis_tabs], |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| with gr.Tab("📥 Drops", id="jarvis-drops"): |
| gr.Markdown( |
| "**Tous les fichiers produits** par l'agent (sous-graphes, " |
| "enrichissements, audits, signalements, stats) sont listés " |
| "ici, du PLUS RÉCENT au PLUS ANCIEN. Aucun écrasement : les " |
| "collisions de nom sont suffixées automatiquement (`_2`, `_3`…). " |
| "La liste se rafraîchit toute seule toutes les 3 secondes. " |
| "Sélectionne un fichier puis **📤 Soumettre à JDM** pour le " |
| "pousser au LLMDrops directement depuis ici." |
| ) |
|
|
| |
| PRODUCTIONS_OLDIES_THRESHOLD_SEC = 48 * 3600 |
| PRODUCTIONS_OLDIES_DIR = PRODUCTIONS_DIR / "oldies" |
| |
| |
| |
| PRODUCTIONS_SUBMITTED_FILE = PRODUCTIONS_DIR / ".submitted.json" |
|
|
| def _load_submitted_set() -> set: |
| """Charge l'ensemble des noms de fichiers deja soumis.""" |
| import json as _j_sub |
| if not PRODUCTIONS_SUBMITTED_FILE.exists(): |
| return set() |
| try: |
| data = _j_sub.loads( |
| PRODUCTIONS_SUBMITTED_FILE.read_text(encoding="utf-8") |
| ) |
| if isinstance(data, dict): |
| return set(data.keys()) |
| if isinstance(data, list): |
| return set(data) |
| except Exception: |
| pass |
| return set() |
|
|
| def _mark_submitted(filename: str): |
| """Ajoute filename au registre des fichiers soumis.""" |
| import json as _j_sub |
| import time as _t_sub |
| try: |
| current = {} |
| if PRODUCTIONS_SUBMITTED_FILE.exists(): |
| try: |
| current = _j_sub.loads( |
| PRODUCTIONS_SUBMITTED_FILE.read_text(encoding="utf-8") |
| ) |
| if not isinstance(current, dict): |
| current = {} |
| except Exception: |
| current = {} |
| current[filename] = _t_sub.time() |
| PRODUCTIONS_DIR.mkdir(exist_ok=True) |
| PRODUCTIONS_SUBMITTED_FILE.write_text( |
| _j_sub.dumps(current, ensure_ascii=False, indent=2), |
| encoding="utf-8", |
| ) |
| except Exception: |
| pass |
|
|
| def _move_old_files_to_oldies(): |
| """Deplace les fichiers du root PRODUCTIONS_DIR plus |
| vieux que 48h vers PRODUCTIONS_DIR/oldies/. Idempotent, |
| appele par chaque tick du Timer. |
| """ |
| import time as _t_prod |
| if not PRODUCTIONS_DIR.exists(): |
| return |
| now = _t_prod.time() |
| PRODUCTIONS_OLDIES_DIR.mkdir(exist_ok=True) |
| for p in PRODUCTIONS_DIR.iterdir(): |
| if not p.is_file(): |
| continue |
| try: |
| age = now - p.stat().st_mtime |
| if age >= PRODUCTIONS_OLDIES_THRESHOLD_SEC: |
| dst = PRODUCTIONS_OLDIES_DIR / p.name |
| |
| |
| if dst.exists(): |
| dst = PRODUCTIONS_OLDIES_DIR / f"{int(p.stat().st_mtime)}_{p.name}" |
| p.rename(dst) |
| except OSError: |
| continue |
|
|
| def _format_file_entry(p, now, submitted_set): |
| """Label = "✅ name.ext — 1.2KB · 5h" si soumis, |
| sinon sans prefixe. Le ✅ permet aussi a un CSS |
| attribute selector (label*="✅") de teinter |
| differemment la bulle dans le CheckboxGroup.""" |
| st = p.stat() |
| age = int(now - st.st_mtime) |
| if age < 60: |
| age_s = f"{age}s" |
| elif age < 3600: |
| age_s = f"{age // 60}min" |
| elif age < 86400: |
| age_s = f"{age // 3600}h" |
| else: |
| age_s = f"{age // 86400}j" |
| sz_kb = st.st_size / 1024 |
| prefix = "✅ " if p.name in submitted_set else "" |
| return (f"{prefix}{p.name} — {sz_kb:.1f}KB · {age_s}", str(p)) |
|
|
| def _scan_productions_choices(): |
| """Liste (label, path) des fichiers RECENTS (root), |
| triee par mtime DESC. Auto-archive les >48h en oldies |
| AVANT le scan.""" |
| _move_old_files_to_oldies() |
| if not PRODUCTIONS_DIR.exists(): |
| return [] |
| import time as _t_prod |
| now = _t_prod.time() |
| sub = _load_submitted_set() |
| files = [] |
| for p in PRODUCTIONS_DIR.iterdir(): |
| if not p.is_file() or p.name.startswith("."): |
| continue |
| try: |
| files.append((p, p.stat().st_mtime)) |
| except OSError: |
| continue |
| files.sort(key=lambda x: -x[1]) |
| return [_format_file_entry(p, now, sub) for p, _ in files] |
|
|
| def _scan_oldies_choices(): |
| """Liste (label, path) des fichiers ARCHIVES (oldies/), |
| triee par mtime DESC (le plus recent archive d'abord).""" |
| if not PRODUCTIONS_OLDIES_DIR.exists(): |
| return [] |
| import time as _t_prod |
| now = _t_prod.time() |
| sub = _load_submitted_set() |
| files = [] |
| for p in PRODUCTIONS_OLDIES_DIR.iterdir(): |
| if not p.is_file() or p.name.startswith("."): |
| continue |
| try: |
| files.append((p, p.stat().st_mtime)) |
| except OSError: |
| continue |
| files.sort(key=lambda x: -x[1]) |
| return [_format_file_entry(p, now, sub) for p, _ in files] |
|
|
| with gr.Row(): |
| with gr.Column(scale=1, min_width=320): |
| |
| |
| |
| |
| |
| _initial_recent = _scan_productions_choices() |
| _initial_oldies = _scan_oldies_choices() |
| prod_empty_md = gr.Markdown( |
| "*(aucun fichier récent — lance un flow " |
| "Jarvis pour en générer)*", |
| visible=not bool(_initial_recent), |
| ) |
| prod_file_dropdown = gr.CheckboxGroup( |
| choices=_initial_recent, |
| label="📂 Fichiers récents", |
| value=[], |
| interactive=True, |
| elem_id="prod-file-radio", |
| visible=bool(_initial_recent), |
| ) |
| prod_refresh_btn = gr.Button("🔄 Rafraîchir maintenant", |
| size="sm") |
| |
| |
| |
| with gr.Accordion( |
| "📦 Archives", open=False, |
| ): |
| prod_oldies_empty_md = gr.Markdown( |
| "*(aucune archive pour le moment)*", |
| visible=not bool(_initial_oldies), |
| ) |
| prod_oldies_radio = gr.CheckboxGroup( |
| choices=_initial_oldies, |
| label=None, |
| value=[], |
| interactive=True, |
| elem_id="prod-oldies-radio", |
| visible=bool(_initial_oldies), |
| ) |
| |
| |
| |
| |
| with gr.Group(elem_classes=["admin-only"]): |
| gr.Markdown( |
| "*🔒 Actions admin :*", |
| elem_id="prod-admin-label", |
| ) |
| with gr.Row(): |
| prod_delete_one_btn = gr.Button( |
| "🗑️ Supprimer la sélection", |
| size="sm", |
| interactive=False, |
| ) |
| |
| |
| |
| |
| prod_purge_armed = gr.State(value=False) |
| with gr.Row(): |
| prod_purge_all_btn = gr.Button( |
| "🗑️ Tout vider", |
| size="sm", |
| variant="stop", |
| ) |
| |
| |
| |
| |
| prod_timer = gr.Timer(3.0, active=True) |
| with gr.Column(scale=3): |
| prod_status = gr.Markdown( |
| "*Sélectionne un fichier à gauche pour le visualiser.*" |
| ) |
| prod_html_viewer = gr.HTML(visible=False) |
| prod_text_viewer = gr.Code( |
| visible=False, label="Contenu", |
| language="markdown", lines=30, |
| ) |
| prod_download = gr.File( |
| visible=False, label="📥 Télécharger ce fichier", |
| interactive=False, |
| ) |
| |
| |
| |
| with gr.Row(): |
| prod_submit_btn = gr.Button( |
| "📤 Soumettre la sélection à JDM", |
| variant="primary", |
| interactive=False, |
| ) |
| prod_submit_status = gr.Markdown(visible=False) |
|
|
| def _render_production_file(selected_path): |
| """Affiche le fichier selon son extension : |
| - .html → iframe (data:base64) comme dans Sous-graphe |
| - .enrich/.audit/.err/.stat/.txt/.md → gr.Code |
| - autre → gr.File pour téléchargement |
| Renvoie (status_md, html_html, text_code_update, file_update). |
| """ |
| import base64 as _b64_prod |
| import time as _time_prod |
| if not selected_path: |
| return ( |
| "*Sélectionne un fichier à gauche pour le visualiser.*", |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=None), |
| ) |
| try: |
| sp = Path(selected_path) |
| if not sp.exists(): |
| return ( |
| f"⚠️ Le fichier `{selected_path}` n'existe plus " |
| "(supprimé ou renommé).", |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=None), |
| ) |
| size_kb = sp.stat().st_size / 1024 |
| age_s = int(_time_prod.time() - sp.stat().st_mtime) |
| status_md = ( |
| f"**📄 `{sp.name}`** — {size_kb:.1f} KB " |
| f"(modifié il y a {age_s}s)" |
| ) |
| ext = sp.suffix.lower() |
| if ext == ".html": |
| |
| html_text = sp.read_text(encoding="utf-8") |
| b64 = _b64_prod.b64encode(html_text.encode("utf-8")).decode("ascii") |
| iframe = ( |
| f'<iframe src="data:text/html;base64,{b64}" ' |
| f'style="width:100%;height:780px;border:1px solid #444;' |
| f'border-radius:8px;background:#fff;display:block;" ' |
| f'sandbox="allow-scripts allow-same-origin"></iframe>' |
| ) |
| return ( |
| status_md, |
| gr.update(visible=True, value=iframe), |
| gr.update(visible=False, value=""), |
| gr.update(visible=True, value=str(sp)), |
| ) |
| text_exts = {".enrich", ".audit", ".err", ".stat", |
| ".txt", ".md", ".csv", ".json", ".log"} |
| if ext in text_exts: |
| content = sp.read_text(encoding="utf-8", errors="replace") |
| if len(content) > 200_000: |
| content = content[:200_000] + "\n\n[… tronqué — télécharge pour tout voir]" |
| lang = {"json": "json", "md": "markdown"}.get( |
| ext.lstrip("."), "markdown" |
| ) |
| return ( |
| status_md, |
| gr.update(visible=False, value=""), |
| gr.update(visible=True, value=content, language=lang), |
| gr.update(visible=True, value=str(sp)), |
| ) |
| |
| return ( |
| status_md + " *(type non prévisualisé)*", |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=True, value=str(sp)), |
| ) |
| except Exception as e: |
| return ( |
| f"⚠️ Erreur lecture : {e}", |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=None), |
| ) |
|
|
| |
| |
| |
| def _first_selected(recent_list, oldies_list): |
| if recent_list: |
| return recent_list[0] |
| if oldies_list: |
| return oldies_list[0] |
| return None |
|
|
| |
| |
| |
| |
| def _render_and_toggle(recent_list, oldies_list, drops_key): |
| from jarvis import has_drops_key as _hk |
| path = _first_selected(recent_list, oldies_list) |
| status, html_u, text_u, file_u = _render_production_file(path) |
| n_total = len(recent_list or []) + len(oldies_list or []) |
| has_key = _hk(drops_key) |
| submit_ok = bool(n_total) and has_key |
| submit_label = ( |
| f"📤 Soumettre la sélection ({n_total}) à JDM" |
| if n_total else "📤 Soumettre la sélection à JDM" |
| ) |
| delete_label = ( |
| f"🗑️ Supprimer la sélection ({n_total})" |
| if n_total else "🗑️ Supprimer la sélection" |
| ) |
| return ( |
| status, html_u, text_u, file_u, |
| gr.update(interactive=submit_ok, value=submit_label), |
| gr.update(interactive=bool(n_total), value=delete_label), |
| ) |
|
|
| prod_file_dropdown.change( |
| _render_and_toggle, |
| inputs=[prod_file_dropdown, prod_oldies_radio, |
| jarvis_drops_key], |
| outputs=[prod_status, prod_html_viewer, |
| prod_text_viewer, prod_download, |
| prod_submit_btn, prod_delete_one_btn], |
| ) |
|
|
| |
| |
| def _toggle_submit_only(recent_list, oldies_list, drops_key): |
| from jarvis import has_drops_key as _hk |
| n_total = len(recent_list or []) + len(oldies_list or []) |
| ok = bool(n_total) and _hk(drops_key) |
| return gr.update(interactive=ok) |
|
|
| jarvis_drops_key.change( |
| _toggle_submit_only, |
| inputs=[prod_file_dropdown, prod_oldies_radio, |
| jarvis_drops_key], |
| outputs=[prod_submit_btn], |
| ) |
|
|
| def _submit_production_file(recent_list, oldies_list, |
| drops_key, jarvis_model_v): |
| """Upload TOUS les fichiers selectionnes (union |
| recent + oldies) un par un en serie. Pour chaque |
| succes, ajoute le filename a .submitted.json. |
| Renvoie un compte rendu agrege + refresh des deux |
| CheckboxGroup (pour afficher les nouveaux ✅). |
| """ |
| targets = list(recent_list or []) + list(oldies_list or []) |
| if not targets: |
| return ( |
| gr.update(visible=True, |
| value="⚠️ Aucun fichier sélectionné."), |
| gr.update(), gr.update(), |
| ) |
| from jarvis import submit_existing_file |
| n_ok = 0 |
| n_fail = 0 |
| report_lines = [] |
| for path in targets: |
| name = Path(path).name |
| try: |
| res = submit_existing_file( |
| file_path=path, |
| drops_key=(drops_key or ""), |
| model_name=(jarvis_model_v or "manual_submission"), |
| ) |
| content = "" |
| if isinstance(res, list) and res: |
| last = res[-1] |
| if isinstance(last, dict): |
| content = str(last.get("content", "") or "") |
| else: |
| content = str(last) |
| if content.lstrip().startswith("✅"): |
| n_ok += 1 |
| try: |
| _mark_submitted(name) |
| except Exception: |
| pass |
| report_lines.append(f"- ✅ `{name}`") |
| else: |
| n_fail += 1 |
| |
| first_line = (content.splitlines() or [""])[0][:140] |
| report_lines.append(f"- ❌ `{name}` — {first_line}") |
| except Exception as e: |
| n_fail += 1 |
| report_lines.append(f"- ❌ `{name}` — `{e}`") |
| |
| header = ( |
| f"**Soumission terminée** : ✅ {n_ok} réussi(s) " |
| f"/ ❌ {n_fail} échec(s) sur {len(targets)} fichier(s)." |
| ) |
| full_msg = header + "\n\n" + "\n".join(report_lines) |
| |
| |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| return ( |
| gr.update(visible=True, value=full_msg), |
| gr.update(choices=rec, visible=bool(rec)), |
| gr.update(choices=old, visible=bool(old)), |
| ) |
|
|
| prod_submit_btn.click( |
| _submit_production_file, |
| inputs=[prod_file_dropdown, prod_oldies_radio, |
| jarvis_drops_key, jarvis_model], |
| outputs=[prod_submit_status, |
| prod_file_dropdown, prod_oldies_radio], |
| ) |
|
|
| def _refresh_both_lists(): |
| """Re-scan : déplace les >48h en oldies puis met à |
| jour les deux Radios + leurs sibling « (vide) ». |
| Bascule la visibilité : Radio visible si non-empty, |
| sinon le Markdown placeholder visible.""" |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| return ( |
| gr.update(choices=rec, visible=bool(rec)), |
| gr.update(visible=not bool(rec)), |
| gr.update(choices=old, visible=bool(old)), |
| gr.update(visible=not bool(old)), |
| ) |
|
|
| prod_refresh_btn.click( |
| _refresh_both_lists, |
| inputs=None, |
| outputs=[prod_file_dropdown, prod_empty_md, |
| prod_oldies_radio, prod_oldies_empty_md], |
| ) |
| |
| |
| prod_timer.tick( |
| _refresh_both_lists, |
| inputs=None, |
| outputs=[prod_file_dropdown, prod_empty_md, |
| prod_oldies_radio, prod_oldies_empty_md], |
| ) |
|
|
| |
| |
| |
| prod_oldies_radio.change( |
| _render_and_toggle, |
| inputs=[prod_file_dropdown, prod_oldies_radio, |
| jarvis_drops_key], |
| outputs=[prod_status, prod_html_viewer, |
| prod_text_viewer, prod_download, |
| prod_submit_btn, prod_delete_one_btn], |
| ) |
|
|
| |
| def _delete_selected_files(recent_list, oldies_list): |
| """Supprime TOUS les fichiers cochees dans l'un ou |
| l'autre CheckboxGroup. Retourne status + refresh des |
| deux CheckboxGroup + leurs siblings (vide).""" |
| targets = list(recent_list or []) + list(oldies_list or []) |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| if not targets: |
| return ( |
| "⚠️ Aucun fichier sélectionné.", |
| gr.update(), gr.update(), gr.update(), |
| gr.update(choices=rec, value=[], visible=bool(rec)), |
| gr.update(visible=not bool(rec)), |
| gr.update(choices=old, value=[], visible=bool(old)), |
| gr.update(visible=not bool(old)), |
| gr.update(interactive=False), |
| gr.update(interactive=False, value="🗑️ Supprimer la sélection"), |
| ) |
| n_ok = 0 |
| n_fail = 0 |
| for t in targets: |
| try: |
| p = Path(t) |
| if p.exists(): |
| p.unlink() |
| n_ok += 1 |
| else: |
| n_fail += 1 |
| except Exception: |
| n_fail += 1 |
| msg_parts = [f"🗑️ **{n_ok}** fichier(s) supprimé(s)"] |
| if n_fail: |
| msg_parts.append(f"({n_fail} échec(s))") |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| return ( |
| " ".join(msg_parts), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=None), |
| gr.update(choices=rec, value=[], visible=bool(rec)), |
| gr.update(visible=not bool(rec)), |
| gr.update(choices=old, value=[], visible=bool(old)), |
| gr.update(visible=not bool(old)), |
| gr.update(interactive=False), |
| gr.update(interactive=False, value="🗑️ Supprimer la sélection"), |
| ) |
| prod_delete_one_btn.click( |
| _delete_selected_files, |
| inputs=[prod_file_dropdown, prod_oldies_radio], |
| outputs=[prod_status, prod_html_viewer, prod_text_viewer, |
| prod_download, prod_file_dropdown, |
| prod_empty_md, prod_oldies_radio, |
| prod_oldies_empty_md, prod_submit_btn, |
| prod_delete_one_btn], |
| ) |
|
|
| |
| def _purge_all(armed): |
| """1er clic : arme + change le label en avertissement. |
| 2e clic dans la foulee (armed=True) : suppression |
| effective de TOUS les fichiers (root + oldies).""" |
| if not armed: |
| |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| total = len(rec) + len(old) |
| return ( |
| True, |
| gr.update( |
| value=f"⚠️ Confirmer : supprimer {total} fichier(s) " |
| f"({len(rec)} récents + {len(old)} archives) ?", |
| ), |
| "⚠️ Re-clique pour confirmer (action irréversible).", |
| gr.update(choices=rec, visible=bool(rec)), |
| gr.update(visible=not bool(rec)), |
| gr.update(choices=old, visible=bool(old)), |
| gr.update(visible=not bool(old)), |
| ) |
| |
| n_deleted = 0 |
| n_failed = 0 |
| for dir_ in (PRODUCTIONS_DIR, PRODUCTIONS_OLDIES_DIR): |
| if not dir_.exists(): |
| continue |
| for p in dir_.iterdir(): |
| if not p.is_file(): |
| continue |
| try: |
| p.unlink() |
| n_deleted += 1 |
| except Exception: |
| n_failed += 1 |
| msg_parts = [f"🗑️ **{n_deleted}** fichier(s) supprimé(s)"] |
| if n_failed: |
| msg_parts.append(f"({n_failed} échecs)") |
| rec = _scan_productions_choices() |
| old = _scan_oldies_choices() |
| return ( |
| False, |
| gr.update(value="🗑️ Tout vider"), |
| " ".join(msg_parts), |
| gr.update(choices=rec, value=None, visible=bool(rec)), |
| gr.update(visible=not bool(rec)), |
| gr.update(choices=old, value=None, visible=bool(old)), |
| gr.update(visible=not bool(old)), |
| ) |
| prod_purge_all_btn.click( |
| _purge_all, |
| inputs=[prod_purge_armed], |
| outputs=[prod_purge_armed, prod_purge_all_btn, |
| prod_status, prod_file_dropdown, prod_empty_md, |
| prod_oldies_radio, prod_oldies_empty_md], |
| ) |
|
|
| |
| |
| |
| def _refresh_submit_buttons(drops_key): |
| from jarvis import has_drops_key as _hk |
| ok = _hk(drops_key) |
| return ( |
| gr.update(interactive=ok), |
| gr.update(interactive=ok), |
| gr.update(interactive=ok), |
| gr.update(interactive=ok), |
| ) |
|
|
| jarvis_drops_key.change( |
| _refresh_submit_buttons, |
| inputs=[jarvis_drops_key], |
| outputs=[je_submit, ja_submit, js_submit, jst_submit], |
| ) |
|
|
| |
| |
|
|
| |
| with gr.Tab("🛠️ Aide / Installation"): |
| gr.Markdown(AIDE_MD) |
| gr.Markdown("---") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| with gr.Accordion( |
| "🔐 Export des secrets HF (proprietaire uniquement)", |
| open=False, |
| elem_classes=["admin-only"], |
| ): |
| gr.Markdown( |
| "Pour recuperer tes cles API stockees dans Settings HF " |
| "(reconstituer un `.env` pour deployer ailleurs : VPS, " |
| "serveur dedie, machine locale…). Le mot de passe doit " |
| "etre defini au prealable comme Secret HF nomme " |
| "`EXPORT_SECRETS_PASSWORD`." |
| ) |
| with gr.Row(): |
| _export_pw = gr.Textbox( |
| label="Mot de passe (Secret HF `EXPORT_SECRETS_PASSWORD`)", |
| type="password", placeholder="…", |
| scale=3, |
| ) |
| _export_btn = gr.Button( |
| "🔓 Decrypter et afficher", |
| variant="primary", scale=1, |
| ) |
| _export_status = gr.Markdown(visible=False) |
| _export_textbox = gr.Textbox( |
| label=".env reconstruit (copie ou telecharge)", |
| lines=15, max_lines=30, |
| show_copy_button=True, interactive=False, |
| visible=False, |
| ) |
| _export_dlfile = gr.File( |
| label="⬇️ Telecharger .env", |
| visible=False, interactive=False, |
| ) |
| |
| |
| |
| |
| |
| |
| gr.Markdown("---") |
| gr.Markdown( |
| "**🧹 Purge du cache JDM** — supprime toutes les entrees " |
| "cachees (relations, refinements). Le prochain appel " |
| "rechargera depuis l'API JDM." |
| ) |
| _cache_purge_armed = gr.State(value=False) |
| with gr.Row(): |
| _cache_purge_btn = gr.Button( |
| "🧹 Purger le cache JDM", |
| size="sm", |
| ) |
| _cache_purge_status = gr.Markdown(visible=False) |
|
|
| def _export_secrets(pw: str): |
| """Renvoie le .env reconstitue si le mot de passe match. |
| Cle d'autorisation = Secret HF `EXPORT_SECRETS_PASSWORD`. |
| """ |
| import os as _os |
| import tempfile as _tmp |
| |
| EXPORTABLE_ENV_VARS = [ |
| |
| "ANTHROPIC_API_KEY", |
| "OPENAI_API_KEY", |
| "GOOGLE_API_KEY", |
| "GOOGLE_API_KEYS", |
| "GROQ_API_KEY", |
| "DEEPSEEK_API_KEY", |
| "JDM_DROPS_API_KEY", |
| "EXPORT_SECRETS_PASSWORD", |
| |
| "LLM_PROVIDER", |
| "LLM_MODEL", |
| "LLM_TEMPERATURE", |
| "JDM_BASE_URL", |
| "JDM_TIMEOUT", |
| "JDM_DROPS_URL", |
| "JDM_CACHE_TTL_META", |
| "JDM_CACHE_TTL_DATA", |
| "OLLAMA_BASE_URL", |
| "APP_SUBPATH", |
| ] |
| |
| |
| |
| |
| try: |
| from jdm_agent.enrich.uploader import ( |
| DEFAULT_ENDPOINT_URL as _DROPS_DEFAULT_URL, |
| ) |
| except Exception: |
| _DROPS_DEFAULT_URL = "http://jeuxdemots.org/LLMDrops.php" |
| FALLBACK_DEFAULTS = { |
| "JDM_DROPS_URL": _DROPS_DEFAULT_URL, |
| } |
| expected = _os.environ.get("EXPORT_SECRETS_PASSWORD", "").strip() |
| if not expected: |
| return ( |
| gr.update( |
| visible=True, |
| value="⚠️ **Non configure.** Le Secret HF " |
| "`EXPORT_SECRETS_PASSWORD` n'est pas defini " |
| "cote Space. Ajoute-le dans Settings → " |
| "Variables and secrets, puis re-essaie.", |
| ), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| ) |
| if not pw or pw.strip() != expected: |
| return ( |
| gr.update( |
| visible=True, |
| value="❌ **Mot de passe incorrect.**", |
| ), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| ) |
| |
| lines = [ |
| "# .env reconstitue depuis HF Space", |
| f"# Genere le {_os.environ.get('HF_SPACE_NAME', 'jdmagent')}", |
| "", |
| ] |
| present = [] |
| missing = [] |
| from_default = [] |
| for key in EXPORTABLE_ENV_VARS: |
| val = _os.environ.get(key, "") |
| used_default = False |
| if not val and key in FALLBACK_DEFAULTS: |
| val = FALLBACK_DEFAULTS[key] |
| used_default = True |
| if val: |
| |
| safe = val.replace("\\", "\\\\").replace('"', '\\"') |
| comment = " # valeur par defaut" if used_default else "" |
| lines.append(f'{key}="{safe}"{comment}') |
| if used_default: |
| from_default.append(key) |
| else: |
| present.append(key) |
| else: |
| missing.append(key) |
| content = "\n".join(lines) + "\n" |
| |
| tmp = _tmp.NamedTemporaryFile( |
| mode="w", suffix=".env", prefix="jdmagent_", |
| delete=False, encoding="utf-8", |
| ) |
| tmp.write(content) |
| tmp.close() |
| status = ( |
| f"✅ **{len(present)} variables exportees** depuis " |
| f"l'env : `{', '.join(present)}`" |
| ) |
| if from_default: |
| status += ( |
| f"\n\n🔧 **{len(from_default)} variables avec valeur " |
| f"par defaut** (env non set) : " |
| f"`{', '.join(from_default)}`" |
| ) |
| if missing: |
| status += ( |
| f"\n\nℹ️ **{len(missing)} variables non definies** " |
| f"(omises) : `{', '.join(missing)}`" |
| ) |
| return ( |
| gr.update(visible=True, value=status), |
| gr.update(visible=True, value=content), |
| gr.update(visible=True, value=tmp.name), |
| ) |
|
|
| _export_btn.click( |
| _export_secrets, |
| inputs=[_export_pw], |
| outputs=[_export_status, _export_textbox, _export_dlfile], |
| ) |
|
|
| |
| def _purge_cache(armed): |
| """2 clics : 1er arme, 2e dans la foulee purge. |
| Vide JDM_CACHE_DIR (defaut /tmp/jdm_cache sur HF, |
| .cache/jdm en local).""" |
| import os as _os_cp |
| import shutil as _sh_cp |
| cache_dir = _os_cp.environ.get("JDM_CACHE_DIR", ".cache/jdm") |
| cache_path = Path(cache_dir) |
| if not armed: |
| |
| try: |
| total_bytes = 0 |
| n_files = 0 |
| if cache_path.exists(): |
| for root, _, files in _os_cp.walk(cache_path): |
| for f in files: |
| try: |
| total_bytes += (Path(root) / f).stat().st_size |
| n_files += 1 |
| except OSError: |
| pass |
| size_mb = total_bytes / 1024 / 1024 |
| return ( |
| True, |
| gr.update(value="⚠️ Confirmer la purge"), |
| gr.update( |
| visible=True, |
| value=f"⚠️ **{n_files}** fichier(s) cache " |
| f"(~{size_mb:.1f} MB) dans `{cache_dir}`. " |
| f"Re-clique pour confirmer.", |
| ), |
| ) |
| except Exception as e: |
| return ( |
| False, gr.update(), |
| gr.update(visible=True, value=f"❌ Scan impossible : `{e}`"), |
| ) |
| |
| try: |
| if cache_path.exists(): |
| _sh_cp.rmtree(cache_path, ignore_errors=True) |
| cache_path.mkdir(parents=True, exist_ok=True) |
| |
| |
| try: |
| if "_CLIENT" in globals(): |
| globals()["_CLIENT"] = None |
| except Exception: |
| pass |
| return ( |
| False, |
| gr.update(value="🧹 Purger le cache JDM"), |
| gr.update(visible=True, |
| value=f"✅ Cache vidé (`{cache_dir}`)."), |
| ) |
| except Exception as e: |
| return ( |
| False, |
| gr.update(value="🧹 Purger le cache JDM"), |
| gr.update(visible=True, value=f"❌ Purge échouée : `{e}`"), |
| ) |
|
|
| _cache_purge_btn.click( |
| _purge_cache, |
| inputs=[_cache_purge_armed], |
| outputs=[_cache_purge_armed, _cache_purge_btn, _cache_purge_status], |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| def _refresh_both_dropdowns(): |
| cur = _CURRENT_MODEL or "gemini-3.1-flash-lite" |
| return ( |
| gr.update(choices=build_model_choices(), value=cur), |
| gr.update(choices=build_model_choices(for_jarvis=True), |
| value=_safe_jarvis_value(cur)), |
| ) |
| main_tabs.select( |
| _refresh_both_dropdowns, |
| inputs=None, |
| outputs=[model_in, jarvis_model], |
| show_progress="hidden", |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _switch_api_key(): |
| cur_key = _CURRENT_GEMINI_KEY |
| cur_mod = _CURRENT_MODEL or "gemini-3.1-flash-lite" |
| next_key = pick_unblown_gemini_key(cur_mod, skip=cur_key) |
| if next_key and next_key != cur_key: |
| set_current_gemini_key(next_key) |
| choices_chat = build_model_choices() |
| choices_jarvis = build_model_choices(for_jarvis=True) |
| new_label = _switch_key_btn_label() |
| return ( |
| gr.update(choices=choices_chat), |
| gr.update(choices=choices_jarvis), |
| gr.update(value=new_label), |
| gr.update(value=new_label), |
| ) |
| _switch_outputs = [model_in, jarvis_model, |
| chat_switch_key_btn, jarvis_switch_key_btn] |
| |
| |
| |
| |
| |
| chat_switch_key_btn.click( |
| _switch_api_key, inputs=None, |
| outputs=_switch_outputs, |
| show_progress="hidden", |
| ) |
| jarvis_switch_key_btn.click( |
| _switch_api_key, inputs=None, |
| outputs=_switch_outputs, |
| show_progress="hidden", |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _on_model_change_chat(m: str): |
| set_current_model(m) |
| needs_key = m.startswith("claude-") or m.startswith("gpt-") |
| thinking_update = (gr.update(interactive=True) |
| if m in THINKING_SUPPORTED_MODELS |
| else gr.update(interactive=False, value=False)) |
| |
| |
| |
| choices_chat = build_model_choices() |
| choices_jarvis = build_model_choices(for_jarvis=True) |
| return ( |
| gr.update( |
| interactive=needs_key, |
| placeholder=("sk-ant-… ou sk-…" if needs_key |
| else "Non requis pour les modèles Gemini hébergés"), |
| ), |
| thinking_update, |
| gr.update(choices=choices_chat, value=m), |
| gr.update(choices=choices_jarvis, |
| value=_safe_jarvis_value(m)), |
| ) |
| model_in.change( |
| _on_model_change_chat, |
| inputs=[model_in], |
| outputs=[key_in, chat_thinking, model_in, jarvis_model], |
| show_progress="hidden", |
| ).then( |
| fn=None, inputs=[model_in], outputs=None, |
| js=_thinking_tooltip_js("chat-thinking-cb"), |
| ) |
|
|
| def _on_jarvis_model_change(m: str): |
| set_current_model(m) |
| thinking_update = (gr.update(interactive=True) |
| if m in THINKING_SUPPORTED_MODELS |
| else gr.update(interactive=False, value=False)) |
| |
| |
| choices_chat = build_model_choices() |
| choices_jarvis = build_model_choices(for_jarvis=True) |
| return ( |
| thinking_update, |
| gr.update(choices=choices_chat, value=m), |
| gr.update(choices=choices_jarvis, value=m), |
| ) |
| jarvis_model.change( |
| _on_jarvis_model_change, |
| inputs=[jarvis_model], |
| outputs=[jarvis_thinking, model_in, jarvis_model], |
| show_progress="hidden", |
| ).then( |
| fn=None, inputs=[jarvis_model], outputs=None, |
| js=_thinking_tooltip_js("jarvis-thinking-cb"), |
| ) |
|
|
| gr.Markdown( |
| "---\n*Données : [JeuxDeMots](https://www.jeuxdemots.org) — " |
| "M. Lafourcade, équipe TEXTE, LIRMM/CNRS. " |
| "Projet open-source : [GitHub](https://github.com/expAg/JDMAgent).*" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _subpath = os.environ.get("APP_SUBPATH", "").strip() |
| demo.launch(server_name="0.0.0.0", server_port=7860, |
| allowed_paths=[str(PRODUCTIONS_DIR)], |
| root_path=_subpath, |
| ssr_mode=False, |
| pwa=True) |
|
|