feat(productions): unified output dir + Productions tab + anti-overwrite
Browse filesCENTRALISATION dir : PRODUCTIONS_DIR = /tmp/jdm_outputs (alias VIZ_DIR
conservé pour retro-compat). Tous les producteurs ecrivent ici :
- viz subgraph HTML (etait /tmp/jdm_viz, devient unifié)
- .enrich auto-append (canonical_path du flow Jarvis enrich)
- .audit / .err / .stat (LLM via write_submission_file)
- tout autre write_submission_file (LangChain agent / MCP)
Resultat : un seul dir a scanner cote UI, un seul allowed_paths cote
demo.launch().
ANTI-ECRASEMENT systematique :
- viz subgraph : nom inclut term + HHMMSS (au lieu de hash opaque),
safety counter _2 _3 si meme seconde
- write_submission_file : si fichier existe deja au path force,
suffixe _2.<ext>, _3.<ext>, … (cap a 999 collisions).
Skip si path == auto-append actif (guard existant).
ONGLET PRODUCTIONS (📁) dans Jarvis :
- Sous-onglet a cote de Enrichissement / Audit / etc.
- gr.FileExplorer a gauche (every=5s auto-refresh) sur PRODUCTIONS_DIR
- Viewer adaptatif a droite :
* .html → iframe data:base64 (comme viz_subgraph, sandbox scripts)
* .enrich/.audit/.err/.stat/.txt/.md/.csv/.json/.log → gr.Code
(lecture, syntax highlight, troncature 200k chars)
* autre → gr.File pour telechargement direct
- gr.File toujours present pour permettre clic droit Telecharger
et autres actions natives du browser sur le fichier
- Bouton 🔄 Rafraichir manuel (en plus de l'auto every=5s)
allowed_paths : remplace VIZ_DIR par PRODUCTIONS_DIR (meme valeur).
Tests : 35/35 verts (enrich + tools). Sanity import app OK.
- app.py +161 -6
- src/jdm_agent/tools/jdm_tools.py +20 -0
|
@@ -1778,10 +1778,17 @@ Le LLM produit ces fichiers en local. Pour les pousser à JDM, soit :
|
|
| 1778 |
import base64 as _b64
|
| 1779 |
import tempfile
|
| 1780 |
|
| 1781 |
-
#
|
| 1782 |
-
# (
|
| 1783 |
-
|
| 1784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1785 |
|
| 1786 |
# Liste des relations principales exposées aux formulaires Jarvis +
|
| 1787 |
# Sous-graphe. Sortie au niveau module pour être réutilisable.
|
|
@@ -1824,7 +1831,20 @@ def viz_subgraph(term: str, depth: float,
|
|
| 1824 |
cache_key = (term, depth, top_k, top_k_d2, top_k_d3, top_k_d4,
|
| 1825 |
tuple(rels or ()), tuple(d2_rels or ()),
|
| 1826 |
tuple(d3_rels or ()), tuple(d4_rels or ()))
|
| 1827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1828 |
print(f"[viz] term={term!r} depth={depth} "
|
| 1829 |
f"top_k=[{top_k},{top_k_d2},{top_k_d3},{top_k_d4}] "
|
| 1830 |
f"rels={rels} d2={d2_rels} d3={d3_rels} d4={d4_rels}", flush=True)
|
|
@@ -3850,6 +3870,141 @@ with gr.Blocks(theme=THEME, title="JDMAgent Demo", head=_HEAD_JS, css=_CHATBOT_C
|
|
| 3850 |
outputs=[jst_term, jst_relation, jarvis_tabs],
|
| 3851 |
)
|
| 3852 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3853 |
# ---- Câblage transverse : quand la clé LLMDrops change dans
|
| 3854 |
# le bandeau, on rafraîchit l'état interactive des 4 boutons
|
| 3855 |
# « Soumettre » post-hoc (Enrich/Audit/Signalement/Stats).
|
|
@@ -4016,6 +4171,6 @@ if __name__ == "__main__":
|
|
| 4016 |
# la démo (icône bureau / écran d'accueil mobile, plein écran sans
|
| 4017 |
# barre URL, cache partiel des assets). Aucun coût si non utilisé.
|
| 4018 |
demo.launch(server_name="0.0.0.0", server_port=7860,
|
| 4019 |
-
allowed_paths=[str(
|
| 4020 |
ssr_mode=False,
|
| 4021 |
pwa=True)
|
|
|
|
| 1778 |
import base64 as _b64
|
| 1779 |
import tempfile
|
| 1780 |
|
| 1781 |
+
# RÉPERTOIRE UNIFIÉ des productions JDM Agent.
|
| 1782 |
+
# Tous les flows (sous-graphes HTML, enrichissements .enrich, audits .audit,
|
| 1783 |
+
# signalements .err, stats .stat) écrivent dans ce dossier unique pour :
|
| 1784 |
+
# 1. Centraliser dans l'onglet « 📁 Productions » (FileExplorer)
|
| 1785 |
+
# 2. Simplifier le `allowed_paths` du launch Gradio
|
| 1786 |
+
# 3. Persister sur /tmp (seul dir fiable sur HF Spaces, cf. /tmp/jdm_cache)
|
| 1787 |
+
PRODUCTIONS_DIR = Path("/tmp/jdm_outputs")
|
| 1788 |
+
PRODUCTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
| 1789 |
+
# VIZ_DIR conservé comme ALIAS du dir unifié — pour rétro-compat des
|
| 1790 |
+
# code paths qui le référencent encore. Pas de second répertoire.
|
| 1791 |
+
VIZ_DIR = PRODUCTIONS_DIR
|
| 1792 |
|
| 1793 |
# Liste des relations principales exposées aux formulaires Jarvis +
|
| 1794 |
# Sous-graphe. Sortie au niveau module pour être réutilisable.
|
|
|
|
| 1831 |
cache_key = (term, depth, top_k, top_k_d2, top_k_d3, top_k_d4,
|
| 1832 |
tuple(rels or ()), tuple(d2_rels or ()),
|
| 1833 |
tuple(d3_rels or ()), tuple(d4_rels or ()))
|
| 1834 |
+
# Nom incluant terme + timestamp court → lisible dans l'onglet
|
| 1835 |
+
# Productions ET unique par requête (hash trop opaque pour l'UI).
|
| 1836 |
+
# Le timestamp court (HHMMSS) garantit qu'une re-requête sur le
|
| 1837 |
+
# même terme produit un fichier distinct (pas d'écrasement).
|
| 1838 |
+
import time as _time_mod_viz
|
| 1839 |
+
_safe_term = "".join(ch if ch.isalnum() or ch in "_-" else "_"
|
| 1840 |
+
for ch in (term or "x"))[:40]
|
| 1841 |
+
_ts_short = _time_mod_viz.strftime("%H%M%S")
|
| 1842 |
+
out_path = VIZ_DIR / f"viz_{_safe_term}_{_ts_short}.html"
|
| 1843 |
+
# Safety anti-collision (rare : 2 viz exactement à la même seconde)
|
| 1844 |
+
_i = 2
|
| 1845 |
+
while out_path.exists():
|
| 1846 |
+
out_path = VIZ_DIR / f"viz_{_safe_term}_{_ts_short}_{_i}.html"
|
| 1847 |
+
_i += 1
|
| 1848 |
print(f"[viz] term={term!r} depth={depth} "
|
| 1849 |
f"top_k=[{top_k},{top_k_d2},{top_k_d3},{top_k_d4}] "
|
| 1850 |
f"rels={rels} d2={d2_rels} d3={d3_rels} d4={d4_rels}", flush=True)
|
|
|
|
| 3870 |
outputs=[jst_term, jst_relation, jarvis_tabs],
|
| 3871 |
)
|
| 3872 |
|
| 3873 |
+
# ---- Sous-onglet 6 : 📁 Productions ----
|
| 3874 |
+
# Centralise TOUS les fichiers produits par l'agent (sous-graphes
|
| 3875 |
+
# HTML, .enrich, .audit, .err, .stat). Arborescence à gauche,
|
| 3876 |
+
# viewer adaptatif à droite (iframe pour HTML, code pour text,
|
| 3877 |
+
# télécharge pour le reste). Pas d'écrasement : tout file producer
|
| 3878 |
+
# détecte les collisions et suffixe (_2, _3…).
|
| 3879 |
+
with gr.Tab("📁 Productions", id="jarvis-productions"):
|
| 3880 |
+
gr.Markdown(
|
| 3881 |
+
"**Tous les fichiers produits** par l'agent (sous-graphes, "
|
| 3882 |
+
"enrichissements, audits, signalements, stats) sont listés "
|
| 3883 |
+
"ici. Aucun écrasement : les collisions de nom sont suffixées "
|
| 3884 |
+
"automatiquement (`_2`, `_3`…)."
|
| 3885 |
+
)
|
| 3886 |
+
with gr.Row():
|
| 3887 |
+
with gr.Column(scale=1, min_width=280):
|
| 3888 |
+
prod_file_explorer = gr.FileExplorer(
|
| 3889 |
+
root_dir=str(PRODUCTIONS_DIR),
|
| 3890 |
+
glob="*",
|
| 3891 |
+
label="Fichiers",
|
| 3892 |
+
file_count="single",
|
| 3893 |
+
interactive=True,
|
| 3894 |
+
every=5, # auto-refresh toutes les 5s
|
| 3895 |
+
)
|
| 3896 |
+
prod_refresh_btn = gr.Button("🔄 Rafraîchir maintenant",
|
| 3897 |
+
size="sm")
|
| 3898 |
+
with gr.Column(scale=3):
|
| 3899 |
+
prod_status = gr.Markdown(
|
| 3900 |
+
"*Sélectionne un fichier à gauche pour le visualiser.*"
|
| 3901 |
+
)
|
| 3902 |
+
prod_html_viewer = gr.HTML(visible=False)
|
| 3903 |
+
prod_text_viewer = gr.Code(
|
| 3904 |
+
visible=False, label="Contenu",
|
| 3905 |
+
language="markdown", lines=30,
|
| 3906 |
+
)
|
| 3907 |
+
prod_download = gr.File(
|
| 3908 |
+
visible=False, label="📥 Télécharger ce fichier",
|
| 3909 |
+
interactive=False,
|
| 3910 |
+
)
|
| 3911 |
+
|
| 3912 |
+
def _render_production_file(selected_path):
|
| 3913 |
+
"""Affiche le fichier selon son extension :
|
| 3914 |
+
- .html → iframe (data:base64) comme dans Sous-graphe
|
| 3915 |
+
- .enrich/.audit/.err/.stat/.txt/.md → gr.Code
|
| 3916 |
+
- autre → gr.File pour téléchargement
|
| 3917 |
+
Renvoie (status_md, html_html, text_code_update, file_update).
|
| 3918 |
+
"""
|
| 3919 |
+
import base64 as _b64_prod
|
| 3920 |
+
import time as _time_prod
|
| 3921 |
+
if not selected_path:
|
| 3922 |
+
return (
|
| 3923 |
+
"*Sélectionne un fichier à gauche pour le visualiser.*",
|
| 3924 |
+
gr.update(visible=False, value=""),
|
| 3925 |
+
gr.update(visible=False, value=""),
|
| 3926 |
+
gr.update(visible=False, value=None),
|
| 3927 |
+
)
|
| 3928 |
+
try:
|
| 3929 |
+
sp = Path(selected_path)
|
| 3930 |
+
if not sp.exists():
|
| 3931 |
+
return (
|
| 3932 |
+
f"⚠️ Le fichier `{selected_path}` n'existe plus "
|
| 3933 |
+
"(supprimé ou renommé).",
|
| 3934 |
+
gr.update(visible=False, value=""),
|
| 3935 |
+
gr.update(visible=False, value=""),
|
| 3936 |
+
gr.update(visible=False, value=None),
|
| 3937 |
+
)
|
| 3938 |
+
size_kb = sp.stat().st_size / 1024
|
| 3939 |
+
age_s = int(_time_prod.time() - sp.stat().st_mtime)
|
| 3940 |
+
status_md = (
|
| 3941 |
+
f"**📄 `{sp.name}`** — {size_kb:.1f} KB "
|
| 3942 |
+
f"(modifié il y a {age_s}s)"
|
| 3943 |
+
)
|
| 3944 |
+
ext = sp.suffix.lower()
|
| 3945 |
+
if ext == ".html":
|
| 3946 |
+
# iframe data:base64 — comme viz_subgraph
|
| 3947 |
+
html_text = sp.read_text(encoding="utf-8")
|
| 3948 |
+
b64 = _b64_prod.b64encode(html_text.encode("utf-8")).decode("ascii")
|
| 3949 |
+
iframe = (
|
| 3950 |
+
f'<iframe src="data:text/html;base64,{b64}" '
|
| 3951 |
+
f'style="width:100%;height:780px;border:1px solid #444;'
|
| 3952 |
+
f'border-radius:8px;background:#fff;display:block;" '
|
| 3953 |
+
f'sandbox="allow-scripts allow-same-origin"></iframe>'
|
| 3954 |
+
)
|
| 3955 |
+
return (
|
| 3956 |
+
status_md,
|
| 3957 |
+
gr.update(visible=True, value=iframe),
|
| 3958 |
+
gr.update(visible=False, value=""),
|
| 3959 |
+
gr.update(visible=True, value=str(sp)),
|
| 3960 |
+
)
|
| 3961 |
+
text_exts = {".enrich", ".audit", ".err", ".stat",
|
| 3962 |
+
".txt", ".md", ".csv", ".json", ".log"}
|
| 3963 |
+
if ext in text_exts:
|
| 3964 |
+
content = sp.read_text(encoding="utf-8", errors="replace")
|
| 3965 |
+
if len(content) > 200_000:
|
| 3966 |
+
content = content[:200_000] + "\n\n[… tronqué — télécharge pour tout voir]"
|
| 3967 |
+
lang = {"json": "json", "md": "markdown"}.get(
|
| 3968 |
+
ext.lstrip("."), "markdown"
|
| 3969 |
+
)
|
| 3970 |
+
return (
|
| 3971 |
+
status_md,
|
| 3972 |
+
gr.update(visible=False, value=""),
|
| 3973 |
+
gr.update(visible=True, value=content, language=lang),
|
| 3974 |
+
gr.update(visible=True, value=str(sp)),
|
| 3975 |
+
)
|
| 3976 |
+
# Type inconnu : juste téléchargement
|
| 3977 |
+
return (
|
| 3978 |
+
status_md + " *(type non prévisualisé)*",
|
| 3979 |
+
gr.update(visible=False, value=""),
|
| 3980 |
+
gr.update(visible=False, value=""),
|
| 3981 |
+
gr.update(visible=True, value=str(sp)),
|
| 3982 |
+
)
|
| 3983 |
+
except Exception as e:
|
| 3984 |
+
return (
|
| 3985 |
+
f"⚠️ Erreur lecture : {e}",
|
| 3986 |
+
gr.update(visible=False, value=""),
|
| 3987 |
+
gr.update(visible=False, value=""),
|
| 3988 |
+
gr.update(visible=False, value=None),
|
| 3989 |
+
)
|
| 3990 |
+
|
| 3991 |
+
prod_file_explorer.change(
|
| 3992 |
+
_render_production_file,
|
| 3993 |
+
inputs=[prod_file_explorer],
|
| 3994 |
+
outputs=[prod_status, prod_html_viewer,
|
| 3995 |
+
prod_text_viewer, prod_download],
|
| 3996 |
+
)
|
| 3997 |
+
|
| 3998 |
+
def _refresh_explorer():
|
| 3999 |
+
"""Force un rafraîchissement de l'arborescence."""
|
| 4000 |
+
return gr.update(root_dir=str(PRODUCTIONS_DIR))
|
| 4001 |
+
|
| 4002 |
+
prod_refresh_btn.click(
|
| 4003 |
+
_refresh_explorer,
|
| 4004 |
+
inputs=None,
|
| 4005 |
+
outputs=[prod_file_explorer],
|
| 4006 |
+
)
|
| 4007 |
+
|
| 4008 |
# ---- Câblage transverse : quand la clé LLMDrops change dans
|
| 4009 |
# le bandeau, on rafraîchit l'état interactive des 4 boutons
|
| 4010 |
# « Soumettre » post-hoc (Enrich/Audit/Signalement/Stats).
|
|
|
|
| 4171 |
# la démo (icône bureau / écran d'accueil mobile, plein écran sans
|
| 4172 |
# barre URL, cache partiel des assets). Aucun coût si non utilisé.
|
| 4173 |
demo.launch(server_name="0.0.0.0", server_port=7860,
|
| 4174 |
+
allowed_paths=[str(PRODUCTIONS_DIR)],
|
| 4175 |
ssr_mode=False,
|
| 4176 |
pwa=True)
|
|
@@ -1760,6 +1760,26 @@ def write_submission_file(
|
|
| 1760 |
_p.parent.mkdir(parents=True, exist_ok=True)
|
| 1761 |
except OSError:
|
| 1762 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1763 |
path = str(_p.resolve())
|
| 1764 |
|
| 1765 |
# Classement par clés — auto-détection : pas de mode explicite.
|
|
|
|
| 1760 |
_p.parent.mkdir(parents=True, exist_ok=True)
|
| 1761 |
except OSError:
|
| 1762 |
pass
|
| 1763 |
+
# ANTI-ÉCRASEMENT : si un fichier au même nom existe déjà, on suffixe
|
| 1764 |
+
# _2, _3, _4… avant l'extension. Sauf si le path est celui géré par
|
| 1765 |
+
# l'auto-append (cf. guard plus bas qui no-op cet appel de toute façon).
|
| 1766 |
+
try:
|
| 1767 |
+
from jdm_agent.enrich import get_consolidation_output_path as _g
|
| 1768 |
+
_is_auto_path = (_g() is not None
|
| 1769 |
+
and str(_Path(_g()).resolve()) == str(_p.resolve()))
|
| 1770 |
+
except Exception:
|
| 1771 |
+
_is_auto_path = False
|
| 1772 |
+
if not _is_auto_path and _p.exists():
|
| 1773 |
+
_stem, _ext = _p.stem, _p.suffix
|
| 1774 |
+
_i = 2
|
| 1775 |
+
while True:
|
| 1776 |
+
_candidate = _p.with_name(f"{_stem}_{_i}{_ext}")
|
| 1777 |
+
if not _candidate.exists():
|
| 1778 |
+
_p = _candidate
|
| 1779 |
+
break
|
| 1780 |
+
_i += 1
|
| 1781 |
+
if _i > 999: # safety, on stoppe à 999 collisions
|
| 1782 |
+
break
|
| 1783 |
path = str(_p.resolve())
|
| 1784 |
|
| 1785 |
# Classement par clés — auto-détection : pas de mode explicite.
|