expAge commited on
Commit
0359a8a
·
1 Parent(s): 3ae2099

feat(productions): unified output dir + Productions tab + anti-overwrite

Browse files

CENTRALISATION 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.

Files changed (2) hide show
  1. app.py +161 -6
  2. src/jdm_agent/tools/jdm_tools.py +20 -0
app.py CHANGED
@@ -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
- # Répertoire des sous-graphes produits — autorisé en lecture par Gradio
1782
- # (cf. demo.launch(allowed_paths=[VIZ_DIR])).
1783
- VIZ_DIR = Path(tempfile.gettempdir()) / "jdm_viz"
1784
- VIZ_DIR.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
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
- out_path = VIZ_DIR / f"viz_{abs(hash(cache_key)) % 10**8}.html"
 
 
 
 
 
 
 
 
 
 
 
 
 
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(VIZ_DIR)],
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)
src/jdm_agent/tools/jdm_tools.py CHANGED
@@ -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.