Compare commits

...

84 Commits

Author SHA1 Message Date
a9bf92a4bc
feat(design) : intégration dans l'Observatoire des Polycrises 2025-06-25 08:42:41 +02:00
2846403860 fix(pda) : résolution de l'affichage tronqué pour les génériques
Oubli de concaténation de la chaîne à afficher pour les préconisations
et les indicateurs génériques.
2025-06-15 18:01:41 +02:00
f259d6b3e3 style(css) : mise à jour de la bonne indentation 2025-06-15 12:02:33 +02:00
c24898bf02 fix(pgpt) : suite à montée de version de docker
régénération du docker-compose.yaml pour pouvoir relancer le service
2025-06-15 12:02:33 +02:00
359d17f628 fix(session) : problème de cache de persistance.py
mise en place de la génération dynamique de toutes les variables
dépendant de session_id
2025-06-15 12:02:33 +02:00
c0ab1f1591 Adaptation pour la gestion des thèmes sur tout le site 2025-06-11 17:31:11 +02:00
67182d8b53 Finalisation de la persistance 2025-06-11 17:05:05 +02:00
8efc016014 Ajout de la persistance 2025-06-11 14:57:53 +02:00
Fabrication du Numérique
4d511cbe23 Continuation dans le typage et la documentation pour les autres app's 2025-06-05 09:28:49 +02:00
Fabrication du Numérique
35aa7d12fa Typage des fonctions de pda et documentation 2025-06-04 21:24:56 +02:00
Fabrication du Numérique
4bb06a4801 Amélioration avec __init__.py 2025-06-04 14:21:55 +02:00
Fabrication du Numérique
c55d478660 Ajout des sélection par chaines critique dans pda 2025-06-03 21:48:38 +02:00
Fabrication du Numérique
9ca623aef1 Découpage modulaire de plan_da_action 2025-06-03 14:53:41 +02:00
Fabrication du Numérique
16b1ad37d7 Create 8d44b9fe.md 2025-06-03 12:52:39 +02:00
Fabrication du Numérique
255361e9aa Améliorations 2025-06-03 12:52:33 +02:00
Fabrication du Numérique
69272a44d6 Améliorations et corrections 2025-06-02 17:00:08 +02:00
Fabrication du Numérique
d47e8608cc Nouvelle analyse avec plan d'actions et correction de schema.txt pour le
traitement du béryllium.
2025-06-02 10:32:09 +02:00
Fabrication du Numérique
8a601aa24a Dernières modifications 2025-05-28 21:13:50 +02:00
Fabrication du Numérique
c2cf505b48 Corrections mineures 2025-05-28 15:29:16 +02:00
Fabrication du Numérique
959e2be867 Modification de TEMP_SECTIONS 2025-05-28 14:41:02 +02:00
Fabrication du Numérique
c5d854b165 Découpage de analyse_ia.py pour faciliter la maintenance 2025-05-28 14:36:30 +02:00
Fabrication du Numérique
95ede9c6f1 Import de private_gpt et amléiorations de l'analyse IA 2025-05-27 17:21:49 +02:00
Fabrication du Numérique
c4fffb829c Modifications suite à analyses 2025-05-26 21:46:47 +02:00
Fabrication du Numérique
981c473204 Ajout du batch de traitement de l'IA 2025-05-26 17:27:59 +02:00
Fabrication du Numérique
5839098db6 Mise à jour et nettoyage 2025-05-25 21:18:38 +02:00
Fabrication du Numérique
81f5bb3b66 Corrections diverses 2025-05-23 21:57:27 +02:00
Fabrication du Numérique
ec00ec3a9b Mise à jour schema, et ajustements en conséquence 2025-05-23 13:35:27 +02:00
Fabrication du Numérique
c5482c3033 Générateur de rapport 2025-05-22 21:19:26 +02:00
Fabrication du Numérique
4809661b0f Amélioration de la génération du rapport 2025-05-22 12:49:54 +02:00
813fb5684e Processus pour IA 2025-05-20 16:52:46 +02:00
b2c47048c7 Delete rag_md.py 2025-05-19 14:27:38 +02:00
54c6a309e6 Update rag_md.py 2025-05-19 14:22:39 +02:00
952f0dd92d Update rag_md.py 2025-05-19 14:17:37 +02:00
d5fffdce14 Create rag_md.py 2025-05-19 14:16:53 +02:00
d8bc030a52 Update rag.py 2025-05-19 13:49:43 +02:00
f8baf851ae On continue avec l'IA 2025-05-19 13:38:30 +02:00
747c56f252 Update rag.py 2025-05-19 09:25:27 +02:00
9c6c857f28 Update rag.py 2025-05-19 09:08:26 +02:00
928a39fd96 Update rag.py 2025-05-19 08:22:39 +02:00
32591990fe Update rag.py 2025-05-19 08:20:34 +02:00
f12bee11b7 Update rag.py 2025-05-19 08:19:42 +02:00
eaeae5f1f5 Update rag.py 2025-05-19 08:18:24 +02:00
4b16c2210e Update rag.py 2025-05-19 08:05:07 +02:00
86a902de9d Update rag.py 2025-05-19 07:59:15 +02:00
4f61b37db1 Update rag.py 2025-05-19 07:55:12 +02:00
e26fc3e20d Update index.py 2025-05-19 07:40:57 +02:00
2c4931bdfe Update rag.py 2025-05-19 07:32:20 +02:00
282a7ad739 Update rag.py 2025-05-19 07:29:28 +02:00
66ce80fe51 Update rag.py 2025-05-19 07:28:25 +02:00
70d856c58b Update index.py 2025-05-19 07:10:06 +02:00
a3608353a2 Improve text chunking to preserve Markdown tables
Enhance split function to detect and preserve Markdown tables when
chunking text. Tables are now kept intact by forcing splits before
and after table content.

Also increase K value from 10 to 30 in rag.py to provide more
passages to the LLM.
2025-05-19 06:45:46 +02:00
f8c630cdfe Update rag.py 2025-05-19 06:34:10 +02:00
8c99fd2da3 Update rag.py 2025-05-19 06:30:35 +02:00
be9c3709db Update rag.py 2025-05-19 06:25:57 +02:00
c56a46545f Update rag.py 2025-05-19 06:24:29 +02:00
852b81ba93 Update rag.py 2025-05-19 06:22:52 +02:00
c1a0a8e072 Update rag.py 2025-05-19 06:21:30 +02:00
a569c71ad4 Update rag.py 2025-05-19 06:19:28 +02:00
b206375c6c Changement de méthode d'IA 2025-05-18 21:38:14 +02:00
7d9dccda22 Update index.py 2025-05-18 21:28:01 +02:00
3dc85bd99b Update index.py 2025-05-18 20:44:49 +02:00
50042f6655 Update index.py 2025-05-18 19:27:14 +02:00
89d167a2f8 Update index.py 2025-05-18 19:25:56 +02:00
3f2f13b65f Update index.py 2025-05-18 18:29:44 +02:00
f0f87b64f4 Update index.py 2025-05-18 18:27:05 +02:00
03cc42c22d Update index.py 2025-05-18 18:21:00 +02:00
8f8e041c6b Update index.py 2025-05-18 18:18:52 +02:00
96a083fd72 Update index.py 2025-05-18 18:17:13 +02:00
58f7cbf669 Update index.py 2025-05-18 18:14:41 +02:00
cc54af71e6 Rapports IA 2025-05-18 18:07:07 +02:00
33695092af mise à jour update et ajout de la génération des rapports (temporaire) 2025-05-17 08:54:29 +02:00
427c7d26f5 Ajout ISG dans les pays pour schema.txt 2025-05-17 08:29:34 +02:00
b06c6857ba Actualiser schema.txt 2025-05-17 07:42:41 +02:00
965f4b31cf Mise à jour IHH 2025-05-16 22:27:41 +02:00
5b215e5e5f Replacement de criticite par ics pour rendre cohérent avec les autres
indices.
2025-05-16 16:07:59 +02:00
92bfd442c2 Modification du formulaire des tickets pour le réinitaliser après la
création
2025-05-16 06:36:53 +02:00
fc08a00a6c Ajout du widget html_expander en lieu et place de st.expander 2025-05-15 21:44:59 +02:00
cf604957e3 Amélioration de la documentation 2025-05-15 17:49:52 +02:00
4ae0fbfdb3 Modification de la détection de l'environnement 2025-05-15 07:40:04 +02:00
9491dd076f Ajout de la description globale du projet 2025-05-14 16:58:06 +02:00
c9d3c8422a Ajout de la description globale du projet 2025-05-14 16:57:11 +02:00
45fbd0f277 Ajout de .streamlit 2025-05-14 16:00:57 +02:00
39919ca596 Ajout config.toml pour thème light 2025-05-14 15:01:00 +02:00
a79106569f Modification fichier log pour CO2 2025-05-14 13:39:18 +02:00
182 changed files with 23702 additions and 16962 deletions

1
.env
View File

@ -1,4 +1,3 @@
ENV = "dev"
ENV_CODE = "dev"
PORT=8502
DOT_FILE = "schema.txt"

12
.gitignore vendored
View File

@ -6,25 +6,31 @@
__pycache__/
*.pyo
*.pyd
*.dot
prompt.md
.gitignore
# Ignorer cache et temporaire
.cache/
*.log
*.tmp
*.old
tempo/
tmp/
jobs/
# Ignorer config locale
.ropeproject/
.streamlit/
venv/
.venv/
Local/
HTML/
static/
Corpus/
# Ignorer données Fiches (adapté à ton projet)
Instructions.md
Fiches/
HTML/
static/Fiches/
# Autres spécifiques si besoin
.DS_Store

11
.streamlit/config.toml Normal file
View File

@ -0,0 +1,11 @@
[server]
enableXsrfProtection = false
enableCORS = false
enableStaticServing = true
[client]
showErrorDetails = true
toolbarMode = "minimal"
[theme]
base = "light"

View File

@ -0,0 +1,200 @@
#!/usr/bin/env python3
import os
import re
import yaml
import requests
import argparse
import logging
# Import des fonctions de génération
from app.fiches.generer import (
generer_fiche
)
from app.fiches.utils.fiche_utils import load_seuils
from utils.gitea import charger_arborescence_fiches
from config import GITEA_TOKEN, FICHES_CRITICITE
# Configuration du logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("batch_generation.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def get_fiche_type(md_source):
"""Extrait le type de fiche depuis le frontmatter YAML."""
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
if not front_match:
return "autre"
context = yaml.safe_load(front_match.group(1))
return context.get("type_fiche", "autre")
def get_indice_court(md_source):
"""Extrait l'indice court depuis le frontmatter YAML."""
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
if not front_match:
return None
context = yaml.safe_load(front_match.group(1))
return context.get("indice_court")
def batch_generate_fiches(output_dir="", force_regenerate=False):
"""Génère toutes les fiches en batch en suivant un ordre de priorité."""
# Assurer que les répertoires de sortie existent
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# Toujours créer les répertoires nécessaires
os.makedirs(os.path.join("Fiches"), exist_ok=True)
os.makedirs(os.path.join("HTML"), exist_ok=True)
os.makedirs(os.path.join("static", "Fiches"), exist_ok=True)
# Charger les seuils
try:
seuils = load_seuils("assets/config.yaml")
logger.info("Seuils chargés avec succès")
except Exception as e:
logger.error(f"Erreur lors du chargement des seuils: {e}")
return
# Charger l'arborescence des fiches
try:
arborescence = charger_arborescence_fiches()
logger.info(f"Arborescence chargée: {len(arborescence)} dossiers trouvés")
except Exception as e:
logger.error(f"Erreur lors du chargement de l'arborescence: {e}")
return
# Créer une liste de toutes les fiches avec leurs informations
toutes_fiches = []
for dossier, fiches in arborescence.items():
for fiche in fiches:
toutes_fiches.append({
"dossier": dossier,
"nom": fiche["nom"],
"download_url": fiche["download_url"],
"type": fiche.get("type", "autre")
})
logger.info(f"Total de {len(toutes_fiches)} fiches à traiter")
# Organisation des fiches par type pour respecter l'ordre de priorité
fiches_par_type = {
"criticite": [],
"assemblage": [],
"fabrication": [],
"minerai": [],
"autre": []
}
# Télécharger et catégoriser les fiches
headers = {"Authorization": f"token {GITEA_TOKEN}"}
# Création des listes spécifiques pour faciliter le traitement ordonné
fiches_criticite = []
autres_fiches = []
# Première étape : identifier les fiches de criticité
logger.info("Identification des fiches de criticité...")
for fiche in toutes_fiches:
# Vérifier si c'est une fiche de criticité par deux méthodes
est_criticite = False
# Méthode 1: vérifier par le nom du fichier
if fiche["nom"] in FICHES_CRITICITE:
est_criticite = True
logger.info(f"Fiche de criticité identifiée par nom: {fiche['nom']}")
# Méthode 2: vérifier par le dossier
elif fiche["dossier"] == "Criticités":
est_criticite = True
logger.info(f"Fiche de criticité identifiée par dossier: {fiche['nom']}")
if est_criticite:
fiches_criticite.append(fiche)
else:
autres_fiches.append(fiche)
# Traiter d'abord les fiches de criticité
logger.info(f"Traitement prioritaire des fiches de criticité ({len(fiches_criticite)} fiches)")
criticite_count = 0
for fiche in fiches_criticite:
try:
# Télécharger la fiche de criticité
reponse = requests.get(fiche["download_url"], headers=headers)
reponse.raise_for_status()
md_source = reponse.text
# Générer immédiatement la fiche de criticité
logger.info(f"Génération prioritaire de la fiche de criticité: {fiche['dossier']}/{fiche['nom']}")
generer_fiche(md_source, fiche["dossier"], fiche["nom"], seuils)
# Ajouter à la liste pour le décompte
fiches_par_type["criticite"].append(fiche)
criticite_count += 1
except Exception as e:
logger.error(f"Erreur lors de la génération de la fiche de criticité {fiche['nom']}: {e}")
logger.info(f"{criticite_count} fiches de criticité générées")
# Maintenant catégoriser et traiter les fiches restantes (non-criticité)
for fiche in autres_fiches:
try:
# Télécharger le contenu pour déterminer le type
reponse = requests.get(fiche["download_url"], headers=headers)
reponse.raise_for_status()
md_source = reponse.text
# Déterminer le type
type_fiche = get_fiche_type(md_source)
if type_fiche in fiches_par_type:
fiches_par_type[type_fiche].append({**fiche, "md_source": md_source})
else:
fiches_par_type["autre"].append({**fiche, "md_source": md_source})
except Exception as e:
logger.error(f"Erreur lors du traitement de {fiche['nom']}: {e}")
# Ordre de traitement pour les fiches restantes
ordre_types = ["assemblage", "fabrication", "minerai", "autre"]
# Générer les fiches restantes dans l'ordre spécifié
for type_fiche in ordre_types:
logger.info(f"Traitement des fiches de type '{type_fiche}' ({len(fiches_par_type[type_fiche])} fiches)")
for fiche in fiches_par_type[type_fiche]:
try:
md_source = fiche["md_source"]
dossier = fiche["dossier"]
nom_fichier = fiche["nom"]
# Vérifier si la génération est nécessaire
html_path = os.path.join("HTML", dossier, os.path.splitext(nom_fichier)[0] + ".html")
if force_regenerate or not os.path.exists(html_path):
logger.info(f"Génération de {dossier}/{nom_fichier}")
generer_fiche(md_source, dossier, nom_fichier, seuils)
else:
logger.info(f"Ignoré (déjà généré): {dossier}/{nom_fichier}")
except Exception as e:
logger.error(f"Erreur lors de la génération de {fiche['nom']}: {e}")
logger.info("Génération batch terminée")
def main():
parser = argparse.ArgumentParser(description="Générateur batch de fiches")
parser.add_argument("--output", "-o", default="", help="Répertoire de sortie (laissez vide pour utiliser les dossiers de l'application)")
parser.add_argument("--force", "-f", action="store_true", help="Forcer la régénération de toutes les fiches")
args = parser.parse_args()
logger.info(f"Démarrage de la génération batch (output: {args.output if args.output else 'dossiers par défaut'}, force: {args.force})")
batch_generate_fiches(output_dir=args.output, force_regenerate=args.force)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,96 @@
import os
import re
import shutil
from pathlib import Path
EXCLUDE_DIRS = {"Local"}
MAX_SECTION_LENGTH = 1200 # non utilisé ici car découpe selon présence de ###
def slugify(text):
return re.sub(r'\W+', '-', text.strip()).strip('-').lower()
def split_markdown_sections_refined(content):
lines = content.splitlines()
sections = []
header_level_2 = None
section_lines = []
subsections = []
current_subsection = None
inside_section = False
for line in lines:
if line.startswith("## "):
if header_level_2:
if current_subsection:
subsections.append(current_subsection)
sections.append((header_level_2, section_lines, subsections))
section_lines, subsections = [], []
current_subsection = None
header_level_2 = line[3:].strip()
inside_section = True
elif line.startswith("### ") and inside_section:
if current_subsection:
subsections.append(current_subsection)
current_subsection = (line[4:].strip(), [])
elif inside_section:
if current_subsection:
current_subsection[1].append(line)
else:
section_lines.append(line)
if header_level_2:
if current_subsection:
subsections.append(current_subsection)
sections.append((header_level_2, section_lines, subsections))
return sections
def process_markdown_file(md_path, rel_output_dir):
with open(md_path, encoding="utf-8") as f:
content = f.read()
sections = split_markdown_sections_refined(content)
for idx, (sec_title, sec_lines, subsections) in enumerate(sections):
base_name = f"{idx:02d}-{slugify(sec_title)}"
if subsections:
sec_dir = rel_output_dir / base_name
sec_dir.mkdir(parents=True, exist_ok=True)
with open(sec_dir / "_intro.md", "w", encoding="utf-8") as f_out:
f_out.write(f"## {sec_title}\n")
f_out.write("\n".join(sec_lines).strip())
for sub_idx, (sub_title, sub_lines) in enumerate(subsections):
sub_name = f"{sub_idx:02d}-{slugify(sub_title)}.md"
with open(sec_dir / sub_name, "w", encoding="utf-8") as f_out:
f_out.write(f"### {sub_title}\n")
f_out.write("\n".join(sub_lines).strip())
else:
with open(rel_output_dir / f"{base_name}.md", "w", encoding="utf-8") as f_out:
f_out.write(f"## {sec_title}\n")
f_out.write("\n".join(sec_lines).strip())
def build_corpus_structure():
BASE_DIR = Path(__file__).resolve().parent.parent.parent
print(BASE_DIR)
SOURCE_DIR = BASE_DIR / "Fiches"
DEST_DIR = BASE_DIR / "Corpus"
if DEST_DIR.exists():
shutil.rmtree(DEST_DIR)
DEST_DIR.mkdir(parents=True, exist_ok=True)
for root, _, files in os.walk(SOURCE_DIR):
rel_path = Path(root).relative_to(SOURCE_DIR)
if any(part in EXCLUDE_DIRS for part in rel_path.parts):
continue
for file in files:
if not file.endswith(".md") or ".md." in file:
continue
input_file = Path(root) / file
subdir = rel_path
filename_no_ext = Path(file).stem
output_dir = DEST_DIR / subdir / filename_no_ext
output_dir.mkdir(parents=True, exist_ok=True)
process_markdown_file(input_file, output_dir)
if __name__ == "__main__":
build_corpus_structure()
print("✅ Corpus généré avec succès dans le dossier 'Corpus/'")

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script d'analyse de la structure du graphe DOT pour comprendre
comment intégrer l'ISG dans le générateur de template.
"""
import os
from pathlib import Path
from networkx.drawing.nx_agraph import read_dot
# Chemins
BASE_DIR = Path(__file__).resolve().parent
GRAPH_PATH = BASE_DIR / "graphe.dot"
def analyze_graph_structure(dot_path):
"""Analyse la structure du graphe et affiche ses caractéristiques."""
print(f"Analyse du fichier: {dot_path}")
# Lire le graphe
G = read_dot(dot_path)
# Informations de base
print(f"Nombre total de nœuds: {len(G.nodes())}")
print(f"Nombre total d'arêtes: {len(G.edges())}")
# Analyse des attributs des nœuds
node_attrs = {}
for node, attrs in G.nodes(data=True):
for key in attrs:
if key not in node_attrs:
node_attrs[key] = set()
node_attrs[key].add(attrs[key])
print("\nAttributs des nœuds:")
for attr, values in node_attrs.items():
print(f"- {attr}: {len(values)} valeurs différentes")
if len(values) < 20: # Afficher seulement si le nombre de valeurs est raisonnable
print(f" Valeurs: {', '.join(sorted(values))}")
# Analyse des niveaux (si l'attribut existe)
if 'level' in node_attrs:
print("\nAnalyse par niveau:")
levels = {}
for node, attrs in G.nodes(data=True):
if 'level' in attrs:
level = attrs['level']
if level not in levels:
levels[level] = []
levels[level].append(node)
for level, nodes in sorted(levels.items()):
print(f"- Niveau {level}: {len(nodes)} nœuds")
# Afficher quelques exemples
if len(nodes) < 5:
print(f" Exemples: {', '.join(nodes)}")
else:
print(f" Exemples: {', '.join(nodes[:3])}... (et {len(nodes)-3} autres)")
# Analyse des attributs ISG
print("\nRecherche des attributs ISG:")
isg_nodes = []
for node, attrs in G.nodes(data=True):
if 'isg' in attrs:
isg_nodes.append((node, attrs['isg']))
if isg_nodes:
print(f"- {len(isg_nodes)} nœuds avec attribut ISG")
print(" Exemples:")
for node, isg in isg_nodes[:5]:
print(f" - {node}: ISG = {isg}")
else:
print("- Aucun nœud avec attribut ISG trouvé")
# Analyse des connexions pour les nœuds critiques (IHH)
print("\nAnalyse des nœuds avec IHH:")
ihh_nodes = []
for node, attrs in G.nodes(data=True):
if 'ihh_pays' in attrs or 'ihh_acteurs' in attrs:
ihh_value_pays = attrs.get('ihh_pays', 'N/A')
ihh_value_acteurs = attrs.get('ihh_acteurs', 'N/A')
ihh_nodes.append((node, ihh_value_pays, ihh_value_acteurs))
if ihh_nodes:
print(f"- {len(ihh_nodes)} nœuds avec attributs IHH")
print(" Exemples:")
for node, ihh_pays, ihh_acteurs in ihh_nodes[:5]:
print(f" - {node}: IHH pays = {ihh_pays}, IHH acteurs = {ihh_acteurs}")
# Analyser les connexions de ce nœud
print(f" Connexions sortantes:")
out_edges = list(G.out_edges(node))
if out_edges:
for i, (_, target) in enumerate(out_edges[:3]):
print(f" - Vers {target}")
if len(out_edges) > 3:
print(f" - ... et {len(out_edges)-3} autres")
else:
print(" - Aucune connexion sortante")
print(f" Connexions entrantes:")
in_edges = list(G.in_edges(node))
if in_edges:
for i, (source, _) in enumerate(in_edges[:3]):
print(f" - Depuis {source}")
if len(in_edges) > 3:
print(f" - ... et {len(in_edges)-3} autres")
else:
print(" - Aucune connexion entrante")
else:
print("- Aucun nœud avec attributs IHH trouvé")
# Vérifier si un nœud a un attribut de niveau 99 (ISG supposé)
print("\nRecherche des nœuds de niveau 99 (ISG):")
level_99_nodes = []
for node, attrs in G.nodes(data=True):
if attrs.get('level') == '99':
level_99_nodes.append(node)
if level_99_nodes:
print(f"- {len(level_99_nodes)} nœuds de niveau 99")
print(" Exemples:")
for node in level_99_nodes[:5]:
print(f" - {node}")
# Analyser les connexions de ce nœud
print(f" Connexions entrantes:")
in_edges = list(G.in_edges(node))
if in_edges:
for i, (source, _) in enumerate(in_edges[:3]):
print(f" - Depuis {source}")
if len(in_edges) > 3:
print(f" - ... et {len(in_edges)-3} autres")
else:
print(" - Aucune connexion entrante")
else:
print("- Aucun nœud de niveau 99 trouvé")
def check_isg_paths(dot_path):
"""Vérifie les chemins entre les nœuds critiques (IHH) et les nœuds ISG."""
print("\nAnalyse des chemins entre nœuds IHH et nœuds ISG:")
# Lire le graphe
G = read_dot(dot_path)
# Identifier les nœuds avec IHH
ihh_nodes = []
for node, attrs in G.nodes(data=True):
ihh_pays = attrs.get('ihh_pays', 0)
ihh_acteurs = attrs.get('ihh_acteurs', 0)
try:
ihh_pays = float(ihh_pays)
ihh_acteurs = float(ihh_acteurs)
if ihh_pays > 25 or ihh_acteurs > 25: # Seuil critique
ihh_nodes.append(node)
except (ValueError, TypeError):
pass
if not ihh_nodes:
print("- Aucun nœud IHH critique trouvé")
return
print(f"- {len(ihh_nodes)} nœuds IHH critiques identifiés")
# Pour chaque nœud IHH critique, chercher des chemins vers des nœuds ISG
for node in ihh_nodes[:5]: # Limiter à 5 exemples
print(f"\n Analyse des chemins pour {node}:")
# Analyser les voisins directs
successors = list(G.successors(node))
print(f" - {len(successors)} successeurs directs")
if successors:
for succ in successors[:3]:
print(f" - Vers {succ}")
# Vérifier les attributs de ce successeur
succ_attrs = G.nodes[succ]
print(f" Attributs: {', '.join(f'{k}={v}' for k, v in succ_attrs.items() if k in ['level', 'isg'])}")
# Chercher les successeurs de niveau 2
succ2 = list(G.successors(succ))
print(f" {len(succ2)} successeurs de niveau 2")
if succ2:
for s2 in succ2[:2]:
print(f" - Vers {s2}")
s2_attrs = G.nodes[s2]
print(f" Attributs: {', '.join(f'{k}={v}' for k, v in s2_attrs.items() if k in ['level', 'isg'])}")
# Chercher encore plus loin si nécessaire
succ3 = list(G.successors(s2))
print(f" {len(succ3)} successeurs de niveau 3")
if succ3:
for s3 in succ3[:2]:
print(f" - Vers {s3}")
s3_attrs = G.nodes[s3]
print(f" Attributs: {', '.join(f'{k}={v}' for k, v in s3_attrs.items() if k in ['level', 'isg'])}")
def main():
"""Fonction principale."""
print("=== Analyse de la structure du graphe ===")
analyze_graph_structure(GRAPH_PATH)
print("\n=== Analyse des chemins entre nœuds critiques et ISG ===")
check_isg_paths(GRAPH_PATH)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,146 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import re
import sys
from collections import defaultdict
def extract_paths(file_path):
"""Extrait tous les chemins du fichier rapport_template.md"""
paths = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
# Extraire les lignes qui commencent par "Corpus/"
if line.strip().startswith("Corpus/"):
paths.append(line.strip())
except Exception as e:
print(f"Erreur lors de la lecture du fichier {file_path}: {e}")
sys.exit(1)
return paths
def check_paths(paths, base_dir):
"""Vérifie si les chemins existent dans le système de fichiers"""
results = {
"existing": [],
"missing": [],
"problematic": [] # Chemins qui pourraient nécessiter des corrections
}
for path in paths:
# Vérifier si le chemin est absolu ou relatif
abs_path = os.path.join(base_dir, path)
if os.path.exists(abs_path):
results["existing"].append(path)
else:
# Essayer de détecter des problèmes potentiels
problem_detected = False
# Vérifier les chemins avec "Fiche minerai" ou "Fiche fabrication"
if "Fiche minerai" in path or "Fiche fabrication" in path:
# Problème courant: mauvaise casse ou absence du mot "minerai"
path_lower = path.lower()
if "minerai" not in path_lower and "/minerai/" in path_lower:
corrected_path = path.replace("/Fiche ", "/Fiche minerai ")
if os.path.exists(os.path.join(base_dir, corrected_path)):
results["problematic"].append((path, corrected_path, "Mot 'minerai' manquant"))
problem_detected = True
# Vérifier les chemins SSD
if "SSD25" in path:
corrected_path = path.replace("SSD25", "SSD 2.5")
if os.path.exists(os.path.join(base_dir, corrected_path)):
results["problematic"].append((path, corrected_path, "Format 'SSD25' au lieu de 'SSD 2.5'"))
problem_detected = True
# Si aucun problème spécifique n'a été détecté, marquer comme manquant
if not problem_detected:
results["missing"].append(path)
return results
def find_similar_paths(missing_path, base_dir):
"""Essaie de trouver des chemins similaires pour aider à diagnostiquer le problème"""
missing_parts = missing_path.split('/')
similar_paths = []
# Rechercher dans les sous-répertoires correspondants
search_dir = os.path.join(base_dir, *missing_parts[:-1])
if os.path.exists(search_dir):
for file in os.listdir(search_dir):
if file.endswith('.md'):
similar_path = os.path.join(search_dir, file).replace(base_dir + '/', '')
similar_paths.append(similar_path)
# Si aucun chemin similaire n'est trouvé, remonter d'un niveau
if not similar_paths and len(missing_parts) > 2:
parent_dir = os.path.join(base_dir, *missing_parts[:-2])
if os.path.exists(parent_dir):
for dir_name in os.listdir(parent_dir):
if dir_name.lower() in missing_parts[-2].lower():
dir_path = os.path.join(parent_dir, dir_name)
if os.path.isdir(dir_path):
for file in os.listdir(dir_path):
if file.endswith('.md'):
similar_path = os.path.join(dir_path, file).replace(base_dir + '/', '')
similar_paths.append(similar_path)
return similar_paths
def main():
# Vérifier que nous sommes dans le bon répertoire
script_dir = os.path.dirname(os.path.abspath(__file__))
base_dir = script_dir
# Chemin vers le rapport_template.md
template_path = os.path.join(base_dir, "Corpus", "rapport_template.md")
if not os.path.exists(template_path):
print(f"Erreur: Le fichier {template_path} n'existe pas.")
sys.exit(1)
print("=== Vérification des chemins dans rapport_template.md ===")
# Extraire les chemins
paths = extract_paths(template_path)
print(f"Nombre total de chemins trouvés: {len(paths)}")
# Vérifier les chemins
results = check_paths(paths, base_dir)
# Afficher les résultats
print("\n=== Résultats ===")
print(f"Chemins existants: {len(results['existing'])}")
print(f"Chemins manquants: {len(results['missing'])}")
print(f"Chemins problématiques: {len(results['problematic'])}")
# Afficher les chemins manquants
if results["missing"]:
print("\n=== Chemins manquants ===")
for path in results["missing"]:
print(f"- {path}")
similar = find_similar_paths(path, base_dir)
if similar:
print(" Chemins similaires trouvés:")
for sim_path in similar[:3]: # Limiter à 3 suggestions
print(f" * {sim_path}")
# Afficher les chemins problématiques avec suggestions
if results["problematic"]:
print("\n=== Chemins problématiques ===")
for orig, corrected, reason in results["problematic"]:
print(f"- {orig}")
print(f" Suggestion: {corrected}")
print(f" Raison: {reason}")
# Résumé
if not results["missing"] and not results["problematic"]:
print("\nTous les chemins dans le rapport sont valides !")
else:
print("\nDes chemins problématiques ont été détectés. Veuillez corriger les erreurs.")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour remplacer les références de chemins dans le rapport par le contenu des fichiers.
Ajuste automatiquement les niveaux de titres pour maintenir la hiérarchie.
"""
import os
import re
from pathlib import Path
# Chemins de base
BASE_DIR = Path(__file__).resolve().parent
BASE_DIR = BASE_DIR / ".."
CORPUS_DIR = BASE_DIR / "Corpus"
INPUT_PATH = CORPUS_DIR / "rapport_template.md"
OUTPUT_PATH = CORPUS_DIR / "rapport_final.md"
def determine_heading_level(line):
"""Détermine le niveau de titre d'une ligne."""
match = re.match(r'^(#+)\s+', line)
if match:
return len(match.group(1))
return 0
def determine_parent_level(lines, current_index):
"""Détermine le niveau de titre parent pour une ligne donnée."""
# Remonter dans les lignes précédentes pour trouver le titre parent
for i in range(current_index - 1, -1, -1):
level = determine_heading_level(lines[i])
if level > 0:
return level
return 0
def adjust_heading_levels(content, parent_level, is_intro_file=False, is_ivc_section=False):
"""Ajuste les niveaux de titres dans le contenu pour s'adapter à la hiérarchie."""
lines = content.split('\n')
# Si le contenu est vide, retourner une chaîne vide
if not lines:
return ""
# Déterminer le niveau minimum de titre dans le contenu original
min_level = 10
for line in lines:
level = determine_heading_level(line)
if level > 0 and level < min_level:
min_level = level
# Si aucun titre trouvé, simplement supprimer la première ligne si nécessaire
if min_level == 10:
if not is_intro_file and not is_ivc_section and lines:
return '\n'.join(lines[1:])
return content
# Traitement spécial pour les fichiers IVC et intro
if is_ivc_section or is_intro_file:
adjusted_lines = []
# Pour les fichiers IVC ou intro, on garde toutes les lignes mais on ajuste les niveaux des titres
for line in lines:
level = determine_heading_level(line)
if level > 0:
# Nouveau niveau = niveau parent + 1 + (niveau actuel - min_level)
new_level = parent_level + 1 + (level - min_level)
# S'assurer que le niveau ne dépasse pas 6 (limite en markdown)
new_level = min(new_level, 6)
line = re.sub(r'^#+\s+', '#' * new_level + ' ', line)
adjusted_lines.append(line)
else:
# Pour les fichiers standards, on supprime la première ligne
lines = lines[1:]
adjusted_lines = []
# Ajuster les niveaux de titres pour les lignes restantes
for line in lines:
level = determine_heading_level(line)
if level > 0:
# Nouveau niveau = niveau parent + 1 + (niveau actuel - min_level)
new_level = parent_level + 1 + (level - min_level)
# S'assurer que le niveau ne dépasse pas 6 (limite en markdown)
new_level = min(new_level, 6)
line = re.sub(r'^#+\s+', '#' * new_level + ' ', line)
adjusted_lines.append(line)
return '\n'.join(adjusted_lines)
def process_report():
"""Traite le rapport pour remplacer les chemins par le contenu."""
if not os.path.exists(INPUT_PATH):
print(f"Fichier d'entrée introuvable: {INPUT_PATH}")
return
# Lire le rapport
with open(INPUT_PATH, 'r', encoding='utf-8') as f:
lines = f.readlines()
output_lines = []
i = 0
while i < len(lines):
line = lines[i].strip()
# Vérifier si la ligne est un chemin
if line.startswith('Corpus/'):
path = line
full_path = BASE_DIR / path
# Déterminer le niveau de titre parent
parent_level = determine_parent_level(lines, i)
try:
# Lire le contenu du fichier
if os.path.exists(full_path):
# Vérifier si c'est un fichier _intro.md
is_intro_file = os.path.basename(full_path) == "_intro.md"
# Vérifier si c'est une section d'Indice de Vulnérabilité de Concurrence
is_ivc_section = "Vulnérabilité de Concurrence" in line or "/ivc-" in path.lower() or "/fiche technique ivc/" in path.lower()
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
# Ajuster les niveaux de titres
adjusted_content = adjust_heading_levels(content, parent_level, is_intro_file, is_ivc_section)
# Ajouter le contenu ajusté
output_lines.append(f"<!-- Contenu du fichier {path} -->")
output_lines.append(adjusted_content)
output_lines.append(f"<!-- Fin du contenu de {path} -->")
else:
output_lines.append(f"<!-- Fichier non trouvé: {path} -->")
output_lines.append(line)
except Exception as e:
output_lines.append(f"<!-- Erreur lors de la lecture du fichier {path}: {str(e)} -->")
output_lines.append(line)
else:
# Conserver la ligne telle quelle
output_lines.append(line)
i += 1
# Écrire le rapport final
with open(OUTPUT_PATH, 'w', encoding='utf-8') as f:
f.write('\n'.join(output_lines))
print(f"Rapport final généré: {OUTPUT_PATH}")
if __name__ == "__main__":
process_report()

View File

@ -0,0 +1,90 @@
# Script d'Injection Automatique pour PrivateGPT
Ce script permet d'automatiser l'injection de documents dans PrivateGPT à partir d'un répertoire local. Au lieu d'utiliser l'interface utilisateur pour télécharger les fichiers un par un, vous pouvez injecter un dossier entier en une seule commande.
## Prérequis
- Python 3.7 ou supérieur
- PrivateGPT installé et fonctionnel sous Docker
- Accès à l'API REST de PrivateGPT (port 8001 par défaut)
## Installation
1. Clonez ce dépôt ou téléchargez les fichiers dans un dossier
2. Installez les dépendances nécessaires :
```bash
pip install requests
```
## Utilisation
Le script s'utilise en ligne de commande avec différentes options :
```bash
python auto_ingest.py -d REPERTOIRE [-u URL] [-r] [-t THREADS] [--retry RETRY] [--retry-delay RETRY_DELAY] [--timeout TIMEOUT] [--extensions EXT1 EXT2 ...]
```
### Options
- `-d, --directory` : Chemin du répertoire contenant les fichiers à injecter (obligatoire)
- `-u, --url` : URL de l'API PrivateGPT (défaut: http://localhost:8001)
- `-r, --recursive` : Parcourir récursivement les sous-répertoires
- `-t, --threads` : Nombre de threads pour les injections parallèles (défaut: 5)
- `--retry` : Nombre de tentatives en cas d'échec (défaut: 3)
- `--retry-delay` : Délai entre les tentatives en secondes (défaut: 5)
- `--timeout` : Délai d'attente pour chaque requête en secondes (défaut: 300)
- `--extensions` : Liste d'extensions spécifiques à injecter (ex: pdf txt)
## Exemples d'utilisation
### Injection simple d'un répertoire
```bash
python auto_ingest.py -d /chemin/vers/documents
```
### Injection récursive avec extensions spécifiques
```bash
python auto_ingest.py -d /chemin/vers/documents -r --extensions pdf docx txt
```
### Injection avec paramètres avancés
```bash
python auto_ingest.py -d /chemin/vers/documents -r -t 10 --timeout 600 --retry 5
```
## Formats de fichiers supportés
Par défaut, le script reconnaît et traite les formats suivants :
- PDF (.pdf)
- Documents texte (.txt, .md)
- Documents Microsoft Office (.doc, .docx, .ppt, .pptx, .xls, .xlsx)
- CSV (.csv)
- EPUB (.epub)
- HTML (.html, .htm)
## Résolution des problèmes
### Erreur de connexion
Si vous obtenez des erreurs de connexion, vérifiez que :
1. PrivateGPT est bien en cours d'exécution
2. L'URL est correcte (par défaut: http://localhost:8001)
3. Le port 8001 est accessible et n'est pas bloqué par un pare-feu
### Erreurs d'injection
- Si un fichier spécifique ne peut pas être injecté, vérifiez qu'il est d'un format supporté par PrivateGPT
- Pour les fichiers volumineux, vous pouvez augmenter la valeur de `--timeout`
- En cas d'erreurs répétées, augmentez les valeurs de `--retry` et `--retry-delay`
## Logs
Le script génère des logs dans :
- La console (stdout)
- Un fichier pgpt_auto_ingest.log dans le répertoire courant
Ces logs contiennent des informations détaillées sur le processus d'injection.

View File

@ -0,0 +1,254 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script d'injection automatique de documents pour PrivateGPT
Ce script parcourt un répertoire spécifié et injecte tous les fichiers
compatibles dans PrivateGPT via son API REST.
"""
import os
import sys
import time
import argparse
import logging
import requests
from pathlib import Path
from typing import List, Dict, Tuple, Set
from concurrent.futures import ThreadPoolExecutor, as_completed
# Configuration du logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler("pgpt_auto_ingest.log")
]
)
logger = logging.getLogger(__name__)
# Extensions de fichiers couramment supportées par PrivateGPT
SUPPORTED_EXTENSIONS = {
'.pdf', '.txt', '.md', '.doc', '.docx', '.ppt', '.pptx',
'.xls', '.xlsx', '.csv', '.epub', '.html', '.htm'
}
def parse_arguments():
"""Parse les arguments de ligne de commande."""
parser = argparse.ArgumentParser(
description="Injecte automatiquement tous les fichiers d'un répertoire dans PrivateGPT"
)
parser.add_argument(
"-d", "--directory",
type=str,
required=True,
help="Chemin du répertoire contenant les fichiers à injecter"
)
parser.add_argument(
"-u", "--url",
type=str,
default="http://localhost:8001",
help="URL de l'API PrivateGPT (défaut: http://localhost:8001)"
)
parser.add_argument(
"-r", "--recursive",
action="store_true",
help="Parcourir récursivement les sous-répertoires"
)
parser.add_argument(
"-t", "--threads",
type=int,
default=5,
help="Nombre de threads pour les injections parallèles (défaut: 5)"
)
parser.add_argument(
"--retry",
type=int,
default=3,
help="Nombre de tentatives en cas d'échec (défaut: 3)"
)
parser.add_argument(
"--retry-delay",
type=int,
default=5,
help="Délai entre les tentatives en secondes (défaut: 5)"
)
parser.add_argument(
"--timeout",
type=int,
default=300,
help="Délai d'attente pour chaque requête en secondes (défaut: 300)"
)
parser.add_argument(
"--extensions",
nargs="+",
help="Liste d'extensions spécifiques à injecter (ex: .pdf .txt)"
)
return parser.parse_args()
def find_files(directory: str, recursive: bool = False,
extensions: Set[str] = SUPPORTED_EXTENSIONS) -> List[Path]:
"""
Trouve tous les fichiers avec les extensions spécifiées dans le répertoire.
Args:
directory: Répertoire à scanner
recursive: Si True, parcourt aussi les sous-répertoires
extensions: Ensemble d'extensions de fichier à inclure
Returns:
Liste des chemins de fichiers trouvés
"""
directory_path = Path(directory)
if not directory_path.exists() or not directory_path.is_dir():
logger.error(f"Le répertoire {directory} n'existe pas ou n'est pas un répertoire.")
return []
files = []
if recursive:
# Parcours récursif
for root, _, filenames in os.walk(directory):
for filename in filenames:
file_path = Path(root) / filename
if file_path.suffix.lower() in extensions:
files.append(file_path)
else:
# Parcours non récursif
for file_path in directory_path.iterdir():
if file_path.is_file() and file_path.suffix.lower() in extensions:
files.append(file_path)
return files
def ingest_file(file_path: Path, pgpt_url: str, timeout: int,
retry_count: int, retry_delay: int) -> Tuple[Path, bool, str]:
"""
Injecte un fichier dans PrivateGPT.
Args:
file_path: Chemin du fichier à injecter
pgpt_url: URL de base de l'API PrivateGPT
timeout: Délai d'attente pour la requête
retry_count: Nombre de tentatives en cas d'échec
retry_delay: Délai entre les tentatives en secondes
Returns:
Tuple contenant (chemin_fichier, succès, message)
"""
ingest_url = f"{pgpt_url}/v1/ingest/file"
for attempt in range(retry_count):
try:
logger.info(f"Injection de {file_path} (tentative {attempt + 1}/{retry_count})")
with open(file_path, 'rb') as file:
files = {'file': (file_path.name, file, 'application/octet-stream')}
response = requests.post(ingest_url, files=files, timeout=timeout)
if response.status_code == 200:
result = response.json()
doc_ids = result.get('document_ids', [])
logger.info(f"Succès! {file_path} -> {len(doc_ids)} documents créés")
return file_path, True, f"{len(doc_ids)} documents créés"
else:
error_msg = f"Erreur HTTP {response.status_code}: {response.text}"
logger.warning(error_msg)
if attempt < retry_count - 1:
logger.info(f"Nouvelle tentative dans {retry_delay} secondes...")
time.sleep(retry_delay)
else:
return file_path, False, error_msg
except Exception as e:
error_msg = f"Exception: {str(e)}"
logger.warning(error_msg)
if attempt < retry_count - 1:
logger.info(f"Nouvelle tentative dans {retry_delay} secondes...")
time.sleep(retry_delay)
else:
return file_path, False, error_msg
return file_path, False, "Nombre maximum de tentatives atteint"
def main():
"""Fonction principale."""
args = parse_arguments()
# Préparation des extensions si spécifiées
extensions = set(args.extensions) if args.extensions else SUPPORTED_EXTENSIONS
# Assurer que les extensions commencent par un point
extensions = {ext if ext.startswith('.') else f'.{ext}' for ext in extensions}
logger.info(f"Démarrage de l'injection automatique depuis {args.directory}")
logger.info(f"URL PrivateGPT: {args.url}")
logger.info(f"Mode récursif: {args.recursive}")
logger.info(f"Extensions: {', '.join(extensions)}")
# Trouver les fichiers
files = find_files(args.directory, args.recursive, extensions)
total_files = len(files)
if total_files == 0:
logger.warning(f"Aucun fichier trouvé avec les extensions {', '.join(extensions)} dans {args.directory}")
return
logger.info(f"Trouvé {total_files} fichiers à injecter")
# Statistiques
successful = 0
failed = 0
failed_files = []
# Injection des fichiers en parallèle
with ThreadPoolExecutor(max_workers=args.threads) as executor:
futures = {
executor.submit(
ingest_file,
file_path,
args.url,
args.timeout,
args.retry,
args.retry_delay
): file_path for file_path in files
}
for future in as_completed(futures):
file_path, success, message = future.result()
if success:
successful += 1
else:
failed += 1
failed_files.append((file_path, message))
# Afficher la progression
progress = (successful + failed) / total_files * 100
logger.info(f"Progression: {progress:.1f}% ({successful + failed}/{total_files})")
# Rapport final
logger.info("="*50)
logger.info("RAPPORT D'INJECTION")
logger.info("="*50)
logger.info(f"Total des fichiers: {total_files}")
logger.info(f"Succès: {successful}")
logger.info(f"Échecs: {failed}")
if failed > 0:
logger.info("\nDétails des échecs:")
for file_path, message in failed_files:
logger.info(f"- {file_path}: {message}")
logger.info("="*50)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logger.info("\nInterruption par l'utilisateur. Arrêt du processus.")
sys.exit(1)
except Exception as e:
logger.error(f"Erreur non gérée: {str(e)}", exc_info=True)
sys.exit(1)

View File

@ -0,0 +1,90 @@
#!/bin/bash
# Script d'exécution pour auto_ingest.py
# Vérification des dépendances
check_dependencies() {
# Vérifier Python
if ! command -v python3 &> /dev/null; then
echo "Erreur: Python 3 n'est pas installé ou n'est pas dans le PATH"
exit 1
fi
# Vérifier pip et requests
if ! python3 -c "import requests" &> /dev/null; then
echo "Installation de la bibliothèque requests..."
pip3 install requests
if [ $? -ne 0 ]; then
echo "Erreur: Impossible d'installer la bibliothèque requests"
echo "Exécutez manuellement: pip3 install requests"
exit 1
fi
fi
}
# Fonction d'aide
show_help() {
echo "Script d'injection automatique pour PrivateGPT"
echo ""
echo "Usage: $0 [options] -d RÉPERTOIRE"
echo ""
echo "Options:"
echo " -d, --directory RÉPERTOIRE Répertoire contenant les fichiers à injecter (obligatoire)"
echo " -u, --url URL URL de l'API PrivateGPT (défaut: http://localhost:8001)"
echo " -r, --recursive Parcourir récursivement les sous-répertoires"
echo " -t, --threads N Nombre de threads pour les injections parallèles (défaut: 5)"
echo " --retry N Nombre de tentatives en cas d'échec (défaut: 3)"
echo " --retry-delay N Délai entre les tentatives en secondes (défaut: 5)"
echo " --timeout N Délai d'attente pour chaque requête en secondes (défaut: 300)"
echo " --extensions EXT1 EXT2 ... Liste d'extensions spécifiques à injecter"
echo " -h, --help Afficher cette aide"
echo ""
echo "Exemple: $0 -d /documents -r --extensions pdf docx"
}
# Vérifier si aucun argument n'est fourni
if [ $# -eq 0 ]; then
show_help
exit 1
fi
# Vérifier l'argument d'aide
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
show_help
exit 0
fi
done
# Vérifier la présence de l'argument obligatoire (-d ou --directory)
directory_specified=false
for ((i=1; i<=$#; i++)); do
if [ "${!i}" = "-d" ] || [ "${!i}" = "--directory" ]; then
directory_specified=true
break
fi
done
if [ "$directory_specified" = false ]; then
echo "Erreur: L'option -d/--directory est obligatoire"
echo "Utilisez -h ou --help pour afficher l'aide"
exit 1
fi
# Vérifier les dépendances
check_dependencies
# Chemin au script Python
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="${SCRIPT_DIR}/auto_ingest.py"
# Vérifier que le script Python existe
if [ ! -f "$PYTHON_SCRIPT" ]; then
echo "Erreur: Le script auto_ingest.py n'existe pas dans $SCRIPT_DIR"
exit 1
fi
# Rendre le script Python exécutable
chmod +x "$PYTHON_SCRIPT"
# Exécuter le script Python avec tous les arguments
python3 "$PYTHON_SCRIPT" "$@"

View File

@ -0,0 +1,40 @@
version: '3.8'
services:
privategpt:
image: ghcr.io/zylon-ai/private-gpt:latest
container_name: privategpt
ports:
- "8001:8001"
environment:
- PGPT_PROFILES=local
# Décommentez et modifiez ces variables si vous voulez utiliser un modèle différent
# - PGPT_SETTINGS_LLMS_DEFAULT__MODEL=/models/custom-model.gguf
# - PGPT_SETTINGS_EMBEDDING_DEFAULT__MODEL=/models/custom-embedding-model
volumes:
# Volume persistant pour les données
- privategpt-data:/app/local_data
# Montage du répertoire d'auto-injection
- ./documents_to_ingest:/app/documents_to_ingest
# Montage des modèles personnalisés (décommentez si nécessaire)
# - ./custom_models:/app/models
restart: unless-stopped
# Décommentez ces lignes si vous avez un GPU NVIDIA
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
volumes:
privategpt-data:
name: privategpt-data
# Instructions d'utilisation:
# 1. Copiez ce fichier sous le nom "docker-compose.yml"
# 2. Créez un répertoire "documents_to_ingest" à côté du fichier docker-compose.yml
# 3. Placez vos documents à injecter dans ce répertoire
# 4. Lancez avec la commande: docker-compose up -d
# 5. Utilisez le script d'injection: ./auto_ingest.sh -d documents_to_ingest -u http://localhost:8001

View File

@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Script de nettoyage pour PrivateGPT
Ce script permet de lister et supprimer les documents ingérés dans PrivateGPT.
Options:
- Lister tous les documents
- Supprimer des documents par préfixe (ex: "temp_section_")
- Supprimer des documents par motif
- Supprimer tous les documents
Usage:
python nettoyer_pgpt.py --list
python nettoyer_pgpt.py --delete-prefix "temp_section_"
python nettoyer_pgpt.py --delete-pattern "rapport_.*\.md"
python nettoyer_pgpt.py --delete-all
"""
import argparse
import json
import re
import requests
import sys
import time
import uuid
from typing import List, Dict, Any, Optional
# Configuration de l'API PrivateGPT
PGPT_URL = "http://127.0.0.1:8001"
API_URL = f"{PGPT_URL}/v1"
def check_api_availability() -> bool:
"""Vérifie si l'API PrivateGPT est disponible"""
try:
response = requests.get(f"{PGPT_URL}/health")
if response.status_code == 200:
print("✅ API PrivateGPT disponible")
return True
else:
print(f"❌ L'API PrivateGPT a retourné le code d'état {response.status_code}")
return False
except requests.RequestException as e:
print(f"❌ Erreur de connexion à l'API PrivateGPT: {e}")
return False
def list_documents() -> List[Dict[str, Any]]:
"""Liste tous les documents ingérés et renvoie la liste des métadonnées"""
try:
# Récupérer la liste des documents
response = requests.get(f"{API_URL}/ingest/list")
response.raise_for_status()
data = response.json()
# Format de réponse OpenAI
if "data" in data:
documents = data.get("data", [])
# Format alternatif
else:
documents = data.get("documents", [])
# Construire une liste normalisée des documents
normalized_docs = []
for doc in documents:
doc_id = doc.get("doc_id") or doc.get("id")
metadata = doc.get("doc_metadata", {})
filename = metadata.get("file_name") or metadata.get("filename", "Inconnu")
normalized_docs.append({
"id": doc_id,
"filename": filename,
"metadata": metadata
})
return normalized_docs
except Exception as e:
print(f"❌ Erreur lors de la récupération des documents: {e}")
return []
def print_documents(documents: List[Dict[str, Any]]) -> None:
"""Affiche la liste des documents de façon lisible"""
if not documents:
print("📋 Aucun document trouvé dans PrivateGPT")
return
print(f"📋 {len(documents)} documents trouvés dans PrivateGPT:")
# Regrouper par nom de fichier pour un affichage plus compact
files_grouped = {}
for doc in documents:
filename = doc["filename"]
if filename not in files_grouped:
files_grouped[filename] = []
files_grouped[filename].append(doc["id"])
# Afficher les résultats groupés
for i, (filename, ids) in enumerate(files_grouped.items(), 1):
print(f"{i}. {filename} ({len(ids)} chunks)")
if args.verbose:
for j, doc_id in enumerate(ids, 1):
print(f" {j}. ID: {doc_id}")
def delete_document(doc_id: str) -> bool:
"""Supprime un document par son ID"""
try:
response = requests.delete(f"{API_URL}/ingest/{doc_id}")
if response.status_code == 200:
return True
else:
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
return False
except Exception as e:
print(f"❌ Erreur lors de la suppression de l'ID {doc_id}: {e}")
return False
def delete_documents_by_criteria(documents: List[Dict[str, Any]],
prefix: Optional[str] = None,
pattern: Optional[str] = None,
delete_all: bool = False) -> int:
"""
Supprime des documents selon différents critères
Retourne le nombre de documents supprimés
"""
if not documents:
print("❌ Aucun document à supprimer")
return 0
if not (prefix or pattern or delete_all):
print("❌ Aucun critère de suppression spécifié")
return 0
# Comptage des suppressions réussies
success_count = 0
# Filtrer les documents à supprimer
docs_to_delete = []
if delete_all:
docs_to_delete = documents
print(f"🗑️ Suppression de tous les documents ({len(documents)} chunks)...")
elif prefix:
docs_to_delete = [doc for doc in documents if doc["filename"].startswith(prefix)]
print(f"🗑️ Suppression des documents dont le nom commence par '{prefix}' ({len(docs_to_delete)} chunks)...")
elif pattern:
try:
regex = re.compile(pattern)
docs_to_delete = [doc for doc in documents if regex.search(doc["filename"])]
print(f"🗑️ Suppression des documents correspondant au motif '{pattern}' ({len(docs_to_delete)} chunks)...")
except re.error as e:
print(f"❌ Expression régulière invalide: {e}")
return 0
# Demander confirmation si beaucoup de documents
if len(docs_to_delete) > 5 and not args.force:
confirm = input(f"⚠️ Vous êtes sur le point de supprimer {len(docs_to_delete)} chunks. Confirmer ? (o/N) ")
if confirm.lower() != 'o':
print("❌ Opération annulée")
return 0
# Supprimer les documents
for doc in docs_to_delete:
if delete_document(doc["id"]):
success_count += 1
if args.verbose:
print(f"✅ Document supprimé: {doc['filename']} (ID: {doc['id']})")
# Petite pause pour éviter de surcharger l'API
time.sleep(0.1)
print(f"{success_count}/{len(docs_to_delete)} documents supprimés avec succès")
return success_count
def generate_unique_prefix() -> str:
"""Génère un préfixe unique basé sur un UUID pour différencier les fichiers temporaires"""
unique_id = str(uuid.uuid4())[:8] # Prendre les 8 premiers caractères de l'UUID
return f"temp_{unique_id}_"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Utilitaire de nettoyage pour PrivateGPT")
# Options principales
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--list", action="store_true", help="Lister tous les documents ingérés")
group.add_argument("--delete-prefix", type=str, help="Supprimer les documents dont le nom commence par PREFIX")
group.add_argument("--delete-pattern", type=str, help="Supprimer les documents dont le nom correspond au motif PATTERN (regex)")
group.add_argument("--delete-all", action="store_true", help="Supprimer tous les documents (⚠️ DANGER)")
group.add_argument("--generate-prefix", action="store_true", help="Générer un préfixe unique pour les fichiers temporaires")
# Options additionnelles
parser.add_argument("--force", action="store_true", help="Ne pas demander de confirmation")
parser.add_argument("--verbose", action="store_true", help="Afficher plus de détails")
args = parser.parse_args()
# Vérifier la disponibilité de l'API
if not check_api_availability():
sys.exit(1)
# Générer un préfixe unique
if args.generate_prefix:
unique_prefix = generate_unique_prefix()
print(f"🔑 Préfixe unique généré: {unique_prefix}")
print(f"Utilisez ce préfixe pour les fichiers temporaires de votre script.")
sys.exit(0)
# Récupérer la liste des documents
documents = list_documents()
# Traiter selon l'option choisie
if args.list:
print_documents(documents)
elif args.delete_prefix:
delete_documents_by_criteria(documents, prefix=args.delete_prefix)
elif args.delete_pattern:
delete_documents_by_criteria(documents, pattern=args.delete_pattern)
elif args.delete_all:
delete_documents_by_criteria(documents, delete_all=True)

View File

@ -0,0 +1,263 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de surveillance de répertoire pour l'injection automatique dans PrivateGPT
Ce script surveille un répertoire et injecte automatiquement les nouveaux fichiers
dans PrivateGPT dès qu'ils sont ajoutés ou modifiés.
"""
import os
import sys
import time
import logging
import argparse
import subprocess
from pathlib import Path
from typing import Set, Dict, List
from datetime import datetime
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
except ImportError:
print("Bibliothèque 'watchdog' non installée. Installation en cours...")
subprocess.run([sys.executable, "-m", "pip", "install", "watchdog"])
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
# Configuration du logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler("pgpt_watch_directory.log")
]
)
logger = logging.getLogger(__name__)
# Extensions de fichiers couramment supportées par PrivateGPT
SUPPORTED_EXTENSIONS = {
'.pdf', '.txt', '.md', '.doc', '.docx', '.ppt', '.pptx',
'.xls', '.xlsx', '.csv', '.epub', '.html', '.htm'
}
class DocumentHandler(FileSystemEventHandler):
"""Gestionnaire d'événements pour les fichiers de documents."""
def __init__(self, watch_dir: str, ingest_script: str, pgpt_url: str,
extensions: Set[str], delay: int = 5):
"""
Initialise le gestionnaire d'événements.
Args:
watch_dir: Répertoire à surveiller
ingest_script: Chemin vers le script d'injection
pgpt_url: URL de l'API PrivateGPT
extensions: Extensions de fichiers à traiter
delay: Délai en secondes à attendre avant le traitement (évite de traiter des fichiers partiellement écrits)
"""
self.watch_dir = os.path.abspath(watch_dir)
self.ingest_script = os.path.abspath(ingest_script)
self.pgpt_url = pgpt_url
self.extensions = extensions
self.delay = delay
# Queue pour les fichiers en attente de traitement
self.pending_files: Dict[str, float] = {}
# Vérifier que le script d'injection existe
if not os.path.exists(self.ingest_script):
logger.error(f"Le script d'injection {self.ingest_script} n'existe pas!")
raise FileNotFoundError(f"Script d'injection introuvable: {self.ingest_script}")
def on_created(self, event):
"""Appelé lorsqu'un fichier est créé."""
if not event.is_directory:
self._handle_file_event(event)
def on_modified(self, event):
"""Appelé lorsqu'un fichier est modifié."""
if not event.is_directory:
self._handle_file_event(event)
def _handle_file_event(self, event):
"""Traite un événement de fichier (création ou modification)."""
file_path = event.src_path
file_ext = os.path.splitext(file_path)[1].lower()
# Ignorer les fichiers non supportés
if file_ext not in self.extensions:
return
# Ignorer les fichiers temporaires et cachés
file_name = os.path.basename(file_path)
if file_name.startswith('.') or file_name.startswith('~') or file_name.endswith('.tmp'):
return
# Ajouter à la queue avec l'horodatage actuel
self.pending_files[file_path] = time.time()
logger.info(f"Fichier détecté: {file_path} (en attente pendant {self.delay} secondes)")
def process_pending_files(self):
"""Traite les fichiers en attente qui ont dépassé le délai d'attente."""
current_time = time.time()
files_to_process: List[str] = []
# Identifier les fichiers prêts à être traités
for file_path, timestamp in list(self.pending_files.items()):
if current_time - timestamp >= self.delay:
if os.path.exists(file_path): # Vérifier que le fichier existe toujours
files_to_process.append(file_path)
self.pending_files.pop(file_path)
# Traiter les fichiers par lot
if files_to_process:
self._ingest_files(files_to_process)
def _ingest_files(self, files: List[str]):
"""
Injecte une liste de fichiers en utilisant le script d'injection.
Args:
files: Liste des chemins de fichiers à injecter
"""
try:
# Créer un répertoire temporaire pour stocker la liste des fichiers
temp_dir = os.path.join(os.path.dirname(self.ingest_script), "temp")
os.makedirs(temp_dir, exist_ok=True)
# Créer un fichier de liste
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
list_file = os.path.join(temp_dir, f"files_to_ingest_{timestamp}.txt")
with open(list_file, "w") as f:
for file_path in files:
f.write(f"{file_path}\n")
# Construire la commande pour le script d'injection
for file_path in files:
file_dir = os.path.dirname(file_path)
logger.info(f"Injection de {file_path}...")
cmd = [
sys.executable,
self.ingest_script,
"-d", file_dir,
"-u", self.pgpt_url,
"--extensions", os.path.splitext(file_path)[1][1:] # Extension sans le point
]
# Exécuter la commande
process = subprocess.run(
cmd,
capture_output=True,
text=True
)
if process.returncode == 0:
logger.info(f"Injection réussie de {file_path}")
else:
logger.error(f"Échec de l'injection de {file_path}: {process.stderr}")
except Exception as e:
logger.error(f"Erreur lors de l'injection des fichiers: {str(e)}")
def parse_arguments():
"""Parse les arguments de ligne de commande."""
parser = argparse.ArgumentParser(
description="Surveille un répertoire et injecte automatiquement les nouveaux fichiers dans PrivateGPT"
)
parser.add_argument(
"-d", "--directory",
type=str,
required=True,
help="Chemin du répertoire à surveiller"
)
parser.add_argument(
"-s", "--script",
type=str,
default=None,
help="Chemin vers le script auto_ingest.py (par défaut: détection automatique)"
)
parser.add_argument(
"-u", "--url",
type=str,
default="http://localhost:8001",
help="URL de l'API PrivateGPT (défaut: http://localhost:8001)"
)
parser.add_argument(
"--delay",
type=int,
default=5,
help="Délai en secondes avant de traiter un nouveau fichier (défaut: 5)"
)
parser.add_argument(
"--extensions",
nargs="+",
help="Liste d'extensions spécifiques à surveiller (ex: pdf txt)"
)
return parser.parse_args()
def main():
"""Fonction principale."""
args = parse_arguments()
# Préparation des extensions si spécifiées
extensions = set(args.extensions) if args.extensions else SUPPORTED_EXTENSIONS
# Assurer que les extensions commencent par un point
extensions = {ext if ext.startswith('.') else f'.{ext}' for ext in extensions}
# Déterminer le chemin du script d'injection
if args.script:
ingest_script = args.script
else:
# Utiliser le script auto_ingest.py dans le même répertoire que ce script
ingest_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auto_ingest.py")
# Créer le répertoire de surveillance s'il n'existe pas
watch_dir = os.path.abspath(args.directory)
if not os.path.exists(watch_dir):
logger.info(f"Création du répertoire de surveillance: {watch_dir}")
os.makedirs(watch_dir, exist_ok=True)
logger.info(f"Démarrage de la surveillance de {watch_dir}")
logger.info(f"URL PrivateGPT: {args.url}")
logger.info(f"Extensions surveillées: {', '.join(extensions)}")
logger.info(f"Délai de traitement: {args.delay} secondes")
# Initialiser le gestionnaire et l'observateur
event_handler = DocumentHandler(
watch_dir=watch_dir,
ingest_script=ingest_script,
pgpt_url=args.url,
extensions=extensions,
delay=args.delay
)
observer = Observer()
observer.schedule(event_handler, path=watch_dir, recursive=True)
observer.start()
try:
logger.info("Surveillance en cours... (Ctrl+C pour quitter)")
while True:
# Traiter les fichiers en attente
event_handler.process_pending_files()
time.sleep(1)
except KeyboardInterrupt:
logger.info("\nInterruption par l'utilisateur. Arrêt de la surveillance.")
observer.stop()
observer.join()
if __name__ == "__main__":
try:
main()
except Exception as e:
logger.error(f"Erreur non gérée: {str(e)}", exc_info=True)
sys.exit(1)

View File

@ -0,0 +1,94 @@
#!/bin/bash
# Script d'exécution pour watch_directory.py
# Vérification des dépendances
check_dependencies() {
# Vérifier Python
if ! command -v python3 &> /dev/null; then
echo "Erreur: Python 3 n'est pas installé ou n'est pas dans le PATH"
exit 1
fi
# Vérifier les bibliothèques Python requises
python3 -c "
try:
import watchdog
except ImportError:
print('Installation de la bibliothèque watchdog...')
import subprocess, sys
subprocess.run([sys.executable, '-m', 'pip', 'install', 'watchdog'])
" || {
echo "Erreur: Impossible d'installer les dépendances"
exit 1
}
}
# Fonction d'aide
show_help() {
echo "Script de surveillance de répertoire pour PrivateGPT"
echo ""
echo "Usage: $0 [options] -d RÉPERTOIRE"
echo ""
echo "Options:"
echo " -d, --directory RÉPERTOIRE Répertoire à surveiller (obligatoire)"
echo " -s, --script CHEMIN Chemin vers le script auto_ingest.py (facultatif)"
echo " -u, --url URL URL de l'API PrivateGPT (défaut: http://localhost:8001)"
echo " --delay N Délai en secondes avant de traiter un nouveau fichier (défaut: 5)"
echo " --extensions EXT1 EXT2 ... Liste d'extensions spécifiques à surveiller"
echo " -h, --help Afficher cette aide"
echo ""
echo "Exemple: $0 -d /documents/à/surveiller --extensions pdf docx"
}
# Vérifier si aucun argument n'est fourni
if [ $# -eq 0 ]; then
show_help
exit 1
fi
# Vérifier l'argument d'aide
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
show_help
exit 0
fi
done
# Vérifier la présence de l'argument obligatoire (-d ou --directory)
directory_specified=false
for ((i=1; i<=$#; i++)); do
if [ "${!i}" = "-d" ] || [ "${!i}" = "--directory" ]; then
directory_specified=true
break
fi
done
if [ "$directory_specified" = false ]; then
echo "Erreur: L'option -d/--directory est obligatoire"
echo "Utilisez -h ou --help pour afficher l'aide"
exit 1
fi
# Vérifier les dépendances
check_dependencies
# Chemin au script Python
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="${SCRIPT_DIR}/watch_directory.py"
# Vérifier que le script Python existe
if [ ! -f "$PYTHON_SCRIPT" ]; then
echo "Erreur: Le script watch_directory.py n'existe pas dans $SCRIPT_DIR"
exit 1
fi
# Rendre le script Python exécutable
chmod +x "$PYTHON_SCRIPT"
# Message d'information sur l'arrêt
echo "Démarrage de la surveillance..."
echo "Appuyez sur Ctrl+C pour arrêter la surveillance"
echo ""
# Exécuter le script Python avec tous les arguments
python3 "$PYTHON_SCRIPT" "$@"

171
IA/get_regeneration_plan.py Normal file
View File

@ -0,0 +1,171 @@
from datetime import datetime
from collections import defaultdict, deque
import os
import sys
from datetime import timezone
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
# À adapter dans ton environnement
from config import GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV
from utils.gitea import recuperer_date_dernier_commit
from IA.make_config import MAKE # MAKE doit être importé depuis un fichier de config
def get_mtime(path):
try:
return datetime.fromtimestamp(os.path.getmtime(path), tz=timezone.utc)
except FileNotFoundError:
return None
def get_commit_time(path_relative):
commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative.replace("Fiches", "Documents")}&sha={ENV}"
return recuperer_date_dernier_commit(commits_url)
def resolve_path_from_where(where_str):
parts = where_str.split(".")
current = MAKE
path_stack = []
for part in parts:
if isinstance(current, dict) and part in current:
path_stack.append((part, current))
current = current[part]
else:
return None
if not isinstance(current, str):
return None
for i in range(len(path_stack) - 1, -1, -1):
key, context = path_stack[i]
if "directory" in context:
directory = context["directory"]
if "fiches" in where_str:
return os.path.join("Fiches", directory, current)
else:
return os.path.join(directory, current)
return None
def identifier_type_fiche(path):
for type_fiche, data in MAKE["fiches"].items():
if not isinstance(data, dict):
continue
directory = data.get("directory", "")
prefix = data.get("prefix", "")
base = os.path.join("Fiches", directory, prefix)
if path.startswith(base):
return type_fiche, data
raise ValueError("Type de fiche non reconnu")
def doit_regenerer(fichier, doc_deps, fiche_data=None):
mtime_fichier = get_mtime(fichier)
if fiche_data:
gitea_dep = fiche_data.get("depends_on", {}).get("gitea", {})
if gitea_dep.get("compare") == "file2commit":
commit_time = get_commit_time(fichier)
if commit_time and mtime_fichier and commit_time > mtime_fichier:
return True
for _, dep in doc_deps.items():
if isinstance(dep, dict) and "where" in dep:
source_path = resolve_path_from_where(dep["where"])
if source_path:
if dep["compare"] == "file2file":
mtime_source = get_mtime(source_path)
elif dep["compare"] == "file2commit":
mtime_source = get_commit_time(source_path)
else:
continue
if mtime_source and mtime_fichier and mtime_source > mtime_fichier:
return True
return False
def get_regeneration_plan(fiche_path):
def build_dependency_graph_complete(path, graph=None, visited=None):
if graph is None:
graph = defaultdict(set)
if visited is None:
visited = set()
if path in visited:
return graph
visited.add(path)
try:
_, fiche_data = identifier_type_fiche(path)
except ValueError:
return graph
depends = fiche_data.get("depends_on", {})
doc_deps = depends.get("document", {})
for _, dep_info in doc_deps.items():
if isinstance(dep_info, dict) and "where" in dep_info:
dep_path = resolve_path_from_where(dep_info["where"])
if dep_path:
graph[path].add(dep_path)
build_dependency_graph_complete(dep_path, graph, visited)
if "file" in fiche_data:
for fichier in fiche_data["file"].values():
dir_fiche = fiche_data.get("directory", "")
fichier_path = os.path.join("Fiches", dir_fiche, fichier)
if fichier_path not in graph:
graph[fichier_path] = set()
return graph
def topological_sort(graph):
in_degree = defaultdict(int)
for node in graph:
for dep in graph[node]:
in_degree[dep] += 1
queue = deque([node for node in graph if in_degree[node] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
for dep in graph[node]:
in_degree[dep] -= 1
if in_degree[dep] == 0:
queue.append(dep)
all_nodes = set(graph.keys()).union(*graph.values())
for node in all_nodes:
if node not in result:
result.append(node)
return result[::-1]
graph = build_dependency_graph_complete(fiche_path)
sorted_fiches = topological_sort(graph)
if fiche_path not in sorted_fiches:
sorted_fiches.append(fiche_path)
to_regen = []
regen_flags = {}
for fiche in sorted_fiches:
print(f"=> {fiche}")
try:
_, fiche_data = identifier_type_fiche(fiche)
except ValueError:
fiche_data = None
depends = fiche_data.get("depends_on", {}) if fiche_data else {}
doc_deps = depends.get("document", {}) if depends else {}
doit = doit_regenerer(fiche, doc_deps, fiche_data)
if any(regen_flags.get(dep, False) for dep in graph.get(fiche, [])):
doit = True
regen_flags[fiche] = doit
if doit:
to_regen.append(fiche)
return to_regen
plan = get_regeneration_plan("Fiches/Minerai/Fiche minerai antimoine.md")
print(plan)

141
IA/make_config.py Normal file
View File

@ -0,0 +1,141 @@
from utils.gitea import recuperer_date_dernier_commit
#
# from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, DEPOT_CODE, ENV, ENV_CODE, DOT_FILE
#
#def recuperer_date_dernier_commit(url):
# headers = {"Authorization": f"token {GITEA_TOKEN}"}
# try:
# response = requests.get(url, headers=headers, timeout=10)
# response.raise_for_status()
# commits = response.json()
# if commits:
# return parser.isoparse(commits[0]["commit"]["author"]["date"])
# except Exception as e:
# logging.error(f"Erreur récupération commit schema : {e}")
# return None
#
# path_relative = f"Documents/{dossier_choisi}/{fiche_choisie}"
# commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative}&sha={ENV}"
#
# local_mtime = datetime.fromtimestamp(os.path.getmtime(path_relative), tz=timezone.utc)
# remote_mtime = recuperer_date_dernier_commit(commit_url)
MAKE = {
"assets": {
"directory": "assets",
"seuils": {
"depends_on": "None",
},
"file": {
"seuils": "config.yaml"
}
},
"fiches": {
"directory": "Fiches",
"criticites": {
"directory": "Criticités",
"préfix": "Fiche technique ",
"depends_on": {
"gitea": {
"compare": "file2commit"
},
"document": {
"seuils": {
"where": "assets.file.seuils",
"compare": "file2file"
}
}
},
"file": {
"ihh": "Fiche technique IHH.md",
"isg": "Fiche technique ISG.md",
"ivc": "Fiche technique IVC.md",
"ics": "Fiche technique ICS.md"
}
},
"assemblage": {
"directory": "Assemblage",
"prefix": "Fiche assemblage ",
"depends_on": {
"gitea": {
"compare": "file2commit"
},
"document": {
"ihh": {
"where": "fiches.criticites.file.ihh",
"compare": "file2file"
},
"isg": {
"where": "fiches.criticites.file.isg",
"compare": "file2file"
}
}
}
},
"fabrication": {
"directory": "Fabrication",
"prefix": "Fiche fabrication ",
"depends_on": {
"gitea": {
"compare": "file2commit"
},
"document": {
"ihh": {
"where": "fiches.criticites.file.ihh",
"compare": "file2file"
},
"isg": {
"where": "fiches.criticites.file.isg",
"compare": "file2file"
}
}
}
},
"connexe": {
"directory": "Connexe",
"prefix": "Fiche assemblage ",
"depends_on": {
"gitea": {
"compare": "file2commit"
},
"document": {
"ihh": {
"where": "fiches.criticites.file.ihh",
"compare": "file2file"
},
"isg": {
"where": "fiches.criticites.file.isg",
"compare": "file2file"
}
}
}
},
"minerai": {
"directory": "Minerai",
"prefix": "Fiche minerai ",
"depends_on": {
"gitea": {
"compare": "file2commit"
},
"document": {
"ihh": {
"where": "fiches.criticites.file.ihh",
"compare": "file2file"
},
"isg": {
"where": "fiches.criticites.file.isg",
"compare": "file2file"
},
"ics": {
"where": "fiches.criticites.file.ics",
"compare": "file2file"
},
"ivc": {
"where": "fiches.criticites.file.ivc",
"compare": "file2file"
}
}
}
}
}
}

93
Instructions.md Normal file
View File

@ -0,0 +1,93 @@
# Bienvenue dans l'application Fabnum
## Présentation de l'application
Cet outil interactif vous permet d'explorer et d'analyser les vulnérabilités de la chaîne de fabrication du numérique. Grâce à une interface, espérons-le, intuitive, vous pourrez visualiser les différentes étapes de production, identifier les points critiques et comprendre les enjeux géopolitiques et plus liés à la fabrication des technologies numériques.
L'application vous offre diverses fonctionnalités :
* Visualisation des données et des graphiques
* Personnalisation des produits à analyser
* Exploration détaillée des indices de vulnérabilité et des opérations
* Possibilité d'exporter les résultats selon différents formats selon autorisation
## Comprendre la chaîne de fabrication numérique
### Structure de la chaîne
La chaîne de fabrication numérique se décompose en trois niveaux hiérarchiques :
* Produits finaux : appareils complets comme les smartphones, ordinateurs, serveurs, etc.
* Composants : éléments constitutifs comme les processeurs, écrans, capteurs, batteries, etc.
* Minerais et matériaux : ressources de base nécessaires à la fabrication des composants
### Opérations à chaque niveau
Chaque niveau de la chaîne implique des opérations spécifiques :
* Niveau Produit final : assemblage des composants pour créer le produit fini
* Niveau Composant : fabrication des pièces à partir des minerais et matériaux
* Niveau Minerai : extraction et traitement des ressources premières
### Dimension géopolitique
Pour chaque opération, l'application détaille :
* Les pays où l'opération est réalisée, avec leur part respective du marché mondial
* Les acteurs économiques impliqués dans chaque pays, avec leur part de marché
* Les liens entre les pays d'opération, les acteurs et leur contexte géopolitique
[Voir la vision détaillée du projet](https://fabnum-dev.peccini.fr/app/static/Description/Objectif%20final.pdf)
## Comprendre les indices de vulnérabilité
L'application utilise quatre indices clés pour évaluer les vulnérabilités dans la chaîne de fabrication numérique :
### Indice de Herfindahl-Hirschmann (IHH)
* Que mesure-t-il ? La concentration géographique ou industrielle d'une opération.
* Comment l'interpréter ? Plus l'indice est élevé, plus l'opération est concentrée dans un nombre limité de pays, ce qui augmente la vulnérabilité.
* Où le trouver ? Associé à chaque opération dans les graphiques d'analyse.
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20IHH.pdf)
### Indice de Vulnérabilité Concurrentielle (IVC)
* Que mesure-t-il ? La pression exercée par d'autres secteurs industriels sur une même ressource minérale.
* Comment l'interpréter ? Un IVC élevé indique une forte compétition pour accéder à ce minerai, augmentant le risque de pénurie.
* Où le trouver ? Associé à chaque minerai dans les visualisations et fiches.
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20IVC.pdf)
### Indice de Criticité de Substituabilité (ICS)
* Que mesure-t-il ? La possibilité de remplacer un minerai par un autre dans la fabrication d'un composant. Plus rarement, il matérialise la capacité de remplacer un procédé.
* Comment l'interpréter ? Un ICS élevé signifie qu'il est difficile de trouver une alternative à ce minerai.
* Où le trouver ? Associé à la relation entre composants et minerais.
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20ICS.pdf)
### Indice de Stabilité Géopolitique (ISG)
* Que mesure-t-il ? La stabilité politique, économique et sociale d'un pays, basée sur trois sous-indicateurs.
* Comment l'interpréter ? Un ISG élevé indique un pays instable, ce qui augmente les risques d'approvisionnement.
* Où le trouver ? Utilisé pour pondérer les risques identifiés tout au long de la chaîne.
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20ISG.pdf)
## Guide d'utilisation de l'application
L'application est organisée en quatre onglets principaux, chacun offrant une perspective différente sur la chaîne de fabrication numérique :
* Onglet Personnalisation : Créer et gérer des produits finaux personnalisés pour des analyses spécifiques.
* À noter : Les produits personnalisés sont temporaires par défaut, mais peuvent être sauvegardés pour une utilisation ultérieure.
* Onglet Analyse : Explorer visuellement les relations entre les différents niveaux de la chaîne de fabrication.
* Exemple d'utilisation : Pour comprendre les vulnérabilités liées aux composants d'un smartphone, sélectionnez « Produit final » comme niveau de départ, « Composant » comme niveau d'arrivée, puis spécifiez « Smartphone » comme item de produit final.
* Onglet IA'nalyse : Obtenez un rapport synthétique circonstancié d'une partie du schéma global.
* Cet onglet n'est accessible qu'aux personnes diposant d'un compte.
* Onglet Plan d'action : Visualiser toutes les criticités d'une chaîne Produit final <-> Composant <-> Minerai.
* À partir d'une sélection, toutes les chaînes critiques sont examinées à la loupe.
* Des propositions d'actions et d'indicateurs sont fournies selon les opérations et leur niveau de criticité.
* Onglet Visualisations : Observer les corrélations entre les différents indices et comprendre les tendances globales.
* Indicateurs clés : Portez attention aux points situés dans les zones de haute valeur pour les deux indices, car ils représentent les vulnérabilités les plus critiques.
* Onglet Fiches : Accéder à des informations détaillées sur chaque opération et minerai.
* À explorer : Les fiches contiennent souvent des informations qui ne sont pas visibles directement dans les graphiques, comme des tendances historiques ou des prévisions futures ; n'hésitez pas à les consulter
En début de chacun des onglets, vous trouverez un mini-guide spécifique sur leur utilisation.

128
README.md
View File

@ -14,7 +14,7 @@ Le code proposé répond à la partie outillage, avec une architecture modulaire
Le projet est bâti sur un backeng Gitea pour la gestion des fiches et des tickets d'évolution. (Accéder au backend)[https://fabnum-git.peccini.fr/FabNum/Fiches]
Le serveur qui héberge l'application héberge aussi le service Gitea, ce qui permet d'éliminer les temps de latence dus au réseau.
Le serveur qui héberge l'application héberge aussi le service Gitea, ce qui permet d'éliminer les temps de latence dus au réseau. Les fiches sont toutefois mise en cache localement en générant les fichiers markdown (référence), html (affichage) et pdf (download).
L'application est écrite en python et utilise majoritairement streamlit.
@ -26,25 +26,44 @@ Le fichier **requirements.txt** permet d'installer tout ce qui est nécessaire p
python -m venv venv
source venv/bin/activate
# Installation depuis le fichier
pip install -r requirements.txt
# Génération du fichier
pipreqs requirements.txt
### Environnement
Le fichier **.env.local** qui contient GITEA_TOKEN n'est pas dans le dépôt car il contient la clé pour accéder au backend.
Le fichier **.env.local** qui contient GITEA_TOKEN n'est pas dans le dépôt car il contient la clé pour accéder au backend. Il doit donc être créé ainsi que le Token. Le TOken doit permetre l'accès en lecture au dépôt et en lecture/écriture au gestionnaire des tickets.
Pour l'environnement de pré-production, (https://fabnum-dev.peccini.fr)[https://fabnum-dev.peccini.fr] :
ENV=dev
ENV_CODE = "dev"
PORT=8502
DOT_FILE = "schema.txt"
GITEA_URL = "https://fabnum-git.peccini.fr/api/v1"
ORGANISATION = "fabnum"
DEPOT_FICHES = "fiches"
DEPOT_CODE = "code"
ID_PROJET = "3"
INSTRUCTIONS = "Instructions.md"
FICHE_IHH = "Fiches/Criticités/Fiche technique IHH.md"
FICHE_ICS = "Fiches/Criticités/Fiche technique ICS.md"
FICHE_ISG = "Fiches/Criticités/Fiche technique ISG.md"
FICHE_IVC = "Fiches/Criticités/Fiche technique IVC.md"
Pour l'environnement de production, (https://fabnum.peccini.fr)[https://fabnum.peccini.fr], le fichier est identique sauf pour :
ENV=public
PORT=8501
La différence entre les deux environnements se fait au travers de la configuration du proxy Nginx (ci-après celle de dev ; il suffit de changer dev en public pour l'environnement de production) :
# Ajout d'un en-tête personnalisé pour indiquer l'environnement
add_header X-Environment "dev" always;
# Transmettre l'en-tête d'environnement au backend
proxy_set_header X-Environment "dev";
Cette configuration est utilisée par config.py.
dev et public sont les deux branches officielles du dépôt.
L'application se lance simplement sous la forme :
@ -66,12 +85,10 @@ Pour automatiser le lancement, il est intégré dans systemd :
[Service]
User=fabnum
WorkingDirectory=/home/fabnum/fabnum-dev
ExecStart=/home/fabnum/fabnum-dev/venv/bin/streamlit run /home/fabnum/fabnum-dev/fa
bnum.py
ExecStart=/home/fabnum/fabnum-dev/venv/bin/streamlit run /home/fabnum/fabnum-dev/fabnum.py --server.port 8502
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
SELinuxContext=system_u:system_r:httpd_t:s0
[Install]
WantedBy=multi-user.target
@ -92,11 +109,19 @@ Le cœur de l'application. Ce script sert de point d'entrée et d'orchestrateur
Ce fichier est conçu de manière modulaire, déléguant les fonctionnalités spécifiques aux modules spécialisés, ce qui facilite la maintenance et les évolutions futures.
Dans la sidebar, l'application indique une estimation des émissions de gaz à effet de serre. Pour cela, un mécanisme est mis en place dans la configuration Nginx pour enregistrer dans le fichier :
/var/log/nginx/fabnum-dev.access.log
les informations d'octets transférés.
Ce système est basé sur la création d'un cookie de session, utilisé ensuite pour distinguer les utilisations.
<<<<<<<<<<<à compléter avec la configuration Nginx>>>>>>>>>>>
## Architecture et principes de conception
### Modularité et simplification
L'application a été restructurée selon les principes suivants :
L'application a été structurée selon les principes suivants :
1. **Séparation des responsabilités** : Chaque module a une fonction bien définie
2. **Modularité** : Les fonctionnalités sont décomposées en composants réutilisables
@ -135,43 +160,56 @@ L'application est organisée de façon modulaire, avec une structure simplifiée
```
fabnum-dev/
├── fabnum.py # Point d'entrée principal
├── config.py # Configuration et variables d'environnement
├── app/ # Modules fonctionnels principaux
│ ├── analyse/ # Module d'analyse des chaînes de dépendance
│ │ ├── interface.py # Interface utilisateur pour l'analyse
│ │ ├── sankey.py # Génération des diagrammes Sankey
│ │ └── README.md # Documentation du module
│ ├── fiches/ # Gestion et affichage des fiches
│ │ ├── interface.py # Interface utilisateur pour les fiches
│ │ ├── generer.py # Génération des fiches
│ │ ├── utils/ # Utilitaires spécifiques aux fiches
│ │ └── README.md # Documentation du module
│ ├── personnalisation/ # Personnalisation de la chaîne
│ │ ├── interface.py # Interface principale
│ │ ├── ajout.py # Ajout de produits
│ │ ├── modification.py # Modification de produits
│ │ ├── import_export.py # Import/export de configurations
│ │ └── README.md # Documentation du module
│ └── visualisations/ # Visualisations graphiques
│ ├── interface.py # Interface des visualisations
│ ├── graphes.py # Gestion des graphes à visualiser
│ └── README.md # Documentation du module
├── components/ # Composants d'interface réutilisables
│ ├── sidebar.py # Barre latérale de navigation
│ ├── header.py # En-tête de l'application
│ ├── footer.py # Pied de page
│ └── README.md # Documentation des composants
├── utils/ # Utilitaires partagés
│ ├── gitea.py # Connexion API Gitea
│ ├── graph_utils.py # Manipulation des graphes
│ └── README.md # Documentation des utilitaires
├── assets/ # Ressources statiques
│ ├── styles/ # Feuilles de style CSS
│ └── impact_co2.js # Calcul d'impact environnemental
├── .env # Configuration versionnée
├── .env.local # Configuration locale (non versionnée)
└── requirements.txt # Dépendances Python
├── fabnum.py # Point d'entrée principal
├── config.py # Configuration et variables d'environnement
├── app/ # Modules fonctionnels principaux
│ ├── analyse/ # Module d'analyse des chaînes de dépendance
│ │ ├── interface.py # Interface utilisateur pour l'analyse
│ │ ├── sankey.py # Génération des diagrammes Sankey
│ │ └── README.md # Documentation du module
│ ├── fiches/ # Gestion et affichage des fiches
│ │ ├── interface.py # Interface utilisateur pour les fiches
│ │ ├── generer.py # Génération des fiches
│ │ ├── utils/ # Utilitaires spécifiques aux fiches
│ │ ├── utils/dynamic # Gestion de la génération et affichage des fiches par type d'opération
│ │ ├── utils/tickets # Gestion de l'affichage et de la création des tickets
│ │ ├── utils/fiches_utils.py # Outils de gestion et rendu des fiches
│ │ └── README.md # Documentation du module
│ ├── personnalisation/ # Personnalisation de la chaîne
│ │ ├── interface.py # Interface principale
│ │ ├── ajout.py # Ajout de produits
│ │ ├── modification.py # Modification de produits
│ │ ├── import_export.py # Import/export de configurations
│ │ └── README.md # Documentation du module
│ └── visualisations/ # Visualisations graphiques
│ ├── interface.py # Interface des visualisations
│ ├── graphes.py # Gestion des graphes à visualiser
│ └── README.md # Documentation du module
├── components/ # Composants d'interface réutilisables
│ ├── sidebar.py # Barre latérale de navigation
│ ├── header.py # En-tête de l'application
│ ├── footer.py # Pied de page
│ ├── connexion.py # Module de connexion à partir d'un token Gitea
│ └── README.md # Documentation des composants
├── utils/ # Utilitaires partagés
│ ├── gitea.py # Connexion API Gitea
│ ├── graph_utils.py # Manipulation des graphes
│ ├── translations.py # Module de gestion de l'internationalisation
│ ├── visualisations.py # Manipulation des graphes de l'onglet Visualisations
│ └── README.md # Documentation des utilitaires
├── assets/ # Ressources statiques
│ ├── locales/ # Gestion de l'internationalisation
│ ├── styles/ # Feuilles de style CSS
│ ├── confir.yaml # Définition des seuils pour les indices
│ ├── fiches_labels.csv # Dictionnaire d'association entre les fiches et leurs labels (tickets)
│ ├── impact_co2.js # Calcul d'impact environnemental
│ ├── licence.md # Licence ajoutée à toutes les fiches
│ └── weakness.png # Icône de l'onglet dans le navigateur
├── .env # Configuration versionnée
├── .env.local # Configuration locale (non versionnée)
└── requirements.txt # Dépendances Python
├── .streamlit/ # Configuration streamlit côté serveur
│ └── config.toml # Fichier de configuration : important theme.base = light
```
Chaque module dispose de sa propre documentation détaillée dans un fichier README.md.

View File

@ -1,2 +1,4 @@
# __init__.py app/fiches
from .interface import interface_analyse
__all_ = [interface_analyse]

View File

@ -1,5 +1,9 @@
from typing import List, Tuple, Dict, Optional
import networkx as nx
import streamlit as st
from utils.translations import _
from utils.widgets import html_expander
from utils.persistance import maj_champ_statut, get_champ_statut, supprime_champ_statut
from .sankey import afficher_sankey
@ -16,8 +20,20 @@ niveau_labels = {
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
def preparer_graphe(G):
"""Nettoie et prépare le graphe pour l'analyse."""
def preparer_graphe(
G: nx.DiGraph,
) -> Tuple[nx.DiGraph, Dict[str, int]]:
"""
Nettoie et prépare le graphe pour l'analyse.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
Tuple[nx.DiGraph, Dict[str, int]]: Un tuple contenant :
- Le graphe NetworkX proprement configuré
- Un dictionnaire des niveaux associés aux nœuds
"""
niveaux_temp = {
node: int(str(attrs.get("niveau")).strip('"'))
for node, attrs in G.nodes(data=True)
@ -28,103 +44,246 @@ def preparer_graphe(G):
[n for n in G.nodes() if niveaux_temp.get(n) == 10 and 'Reserves' in n])
return G, niveaux_temp
def selectionner_niveaux(
) -> Tuple[int|None, int|None]:
"""
Interface pour sélectionner les niveaux de départ et d'arrivée.
def selectionner_niveaux():
"""Interface pour sélectionner les niveaux de départ et d'arrivée."""
Returns:
Tuple[int, int]: Un tuple contenant deux nombres si des nœuds ont été sélectionnés,
- None sinon
"""
st.markdown(f"## {str(_('pages.analyse.selection_nodes'))}")
valeur_defaut = str(_("pages.analyse.select_level"))
niveau_choix = [valeur_defaut] + list(niveau_labels.values())
default_index = next((i for i, opt in enumerate(niveau_choix) if get_champ_statut("pages.analyse.select_level.niveau_depart") in opt), 0)
niveau_depart = st.selectbox(str(_("pages.analyse.start_level")), niveau_choix, key="analyse_niveau_depart")
niveau_depart = st.selectbox(str(_("pages.analyse.start_level")), niveau_choix, index=default_index, key="analyse_niveau_depart")
if niveau_depart == valeur_defaut:
return None, None
else:
maj_champ_statut("pages.analyse.select_level.niveau_depart", niveau_depart)
niveau_depart_int = inverse_niveau_labels[niveau_depart]
niveaux_arrivee_possibles = [v for k, v in niveau_labels.items() if k > niveau_depart_int]
niveaux_arrivee_choix = [valeur_defaut] + niveaux_arrivee_possibles
niveau_choix = [valeur_defaut] + niveaux_arrivee_possibles
default_index = next((i for i, opt in enumerate(niveau_choix) if get_champ_statut("pages.analyse.select_level.niveau_arrivee") in opt), 0)
analyse_niveau_arrivee = st.selectbox(str(_("pages.analyse.end_level")), niveaux_arrivee_choix, key="analyse_niveau_arrivee")
if analyse_niveau_arrivee == valeur_defaut:
niveau_arrivee = st.selectbox(str(_("pages.analyse.end_level")), niveau_choix, index=default_index, key="analyse_niveau_arrivee")
if niveau_arrivee == valeur_defaut:
return niveau_depart_int, None
else:
maj_champ_statut("pages.analyse.select_level.niveau_arrivee", niveau_arrivee)
niveau_arrivee_int = inverse_niveau_labels[analyse_niveau_arrivee]
niveau_arrivee_int = inverse_niveau_labels[niveau_arrivee]
return niveau_depart_int, niveau_arrivee_int
def selectionner_minerais(G: nx.DiGraph, niveau_depart: int, niveau_arrivee: int) -> Optional[List[str]]:
if not (niveau_depart < 2 < niveau_arrivee):
return None
def selectionner_minerais(G, niveau_depart, niveau_arrivee):
"""Interface pour sélectionner les minerais si nécessaire."""
minerais_selection = None
if niveau_depart < 2 < niveau_arrivee:
st.markdown(f"### {str(_('pages.analyse.select_minerals'))}")
# Tous les nœuds de niveau 2 (minerai)
minerais_nodes = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") and int(str(d.get("niveau")).strip('"')) == 2
])
st.markdown(f"### {str(_('pages.analyse.select_minerals'))}")
minerais_selection = st.multiselect(
str(_("pages.analyse.filter_by_minerals")),
minerais_nodes,
key="analyse_minerais"
)
minerais_nodes = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") and int(str(d.get("niveau")).strip('\"')) == 2
])
return minerais_selection
# Initialiser depuis champ_statut si besoin
if "analyse_minerais" not in st.session_state:
anciens = []
i = 0
while True:
m = get_champ_statut(f"pages.analyse.filter_by_minerals.{i}")
if not m:
break
anciens.append(m)
i += 1
st.session_state["analyse_minerais"] = anciens
# Widget multiselect sans default, seulement key
st.multiselect(
str(_("pages.analyse.filter_by_minerals")),
minerais_nodes,
key="analyse_minerais"
)
def selectionner_noeuds(G, niveaux_temp, niveau_depart, niveau_arrivee):
"""Interface pour sélectionner les nœuds spécifiques de départ et d'arrivée."""
selection = st.session_state["analyse_minerais"]
# Toujours purger, puis recréer si nécessaire
supprime_champ_statut("pages.analyse.filter_by_minerals")
if selection:
for i, m in enumerate(selection):
maj_champ_statut(f"pages.analyse.filter_by_minerals.{i}", m)
return selection if selection else None
def selectionner_noeuds(
G: nx.DiGraph,
niveaux_temp: Dict[str, int],
niveau_depart: int,
niveau_arrivee: int
) -> Tuple[List[str]|None, List[str]|None]:
"""
Interface pour sélectionner les nœuds spécifiques de départ et d'arrivée.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
niveaux_temp (Dict[str, int]): Dictionnaire contenant les niveaux associés aux nœuds.
niveau_depart (int): Le niveau de départ sélectionné.
niveau_arrivee (int): Le niveau d'arrivée sélectionné.
Returns:
Optional[Tuple[List[str], List[str]]]: Un tuple contenant les listes des nœuds
- None sinon
"""
st.markdown("---")
st.markdown(f"## {str(_('pages.analyse.fine_selection'))}")
depart_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_depart]
arrivee_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_arrivee]
noeuds_depart = st.multiselect(str(_("pages.analyse.filter_start_nodes")),
sorted(depart_nodes),
key="analyse_noeuds_depart")
noeuds_arrivee = st.multiselect(str(_("pages.analyse.filter_end_nodes")),
sorted(arrivee_nodes),
key="analyse_noeuds_arrivee")
# DEPARTS -------------------------------------
if "analyse_noeuds_depart" not in st.session_state:
anciens_departs = []
i = 0
while True:
val = get_champ_statut(f"pages.analyse.filter_start_nodes.{i}")
if not val:
break
anciens_departs.append(val)
i += 1
st.session_state["analyse_noeuds_depart"] = anciens_departs
noeuds_depart = noeuds_depart if noeuds_depart else None
noeuds_arrivee = noeuds_arrivee if noeuds_arrivee else None
st.multiselect(
str(_("pages.analyse.filter_start_nodes")),
sorted(depart_nodes),
key="analyse_noeuds_depart"
)
return noeuds_depart, noeuds_arrivee
departs_selection = st.session_state["analyse_noeuds_depart"]
supprime_champ_statut("pages.analyse.filter_start_nodes")
if departs_selection:
for i, val in enumerate(departs_selection):
maj_champ_statut(f"pages.analyse.filter_start_nodes.{i}", val)
def configurer_filtres_vulnerabilite():
"""Interface pour configurer les filtres de vulnérabilité."""
# ARRIVEES -------------------------------------
if "analyse_noeuds_arrivee" not in st.session_state:
anciens_arrivees = []
i = 0
while True:
val = get_champ_statut(f"pages.analyse.filter_end_nodes.{i}")
if not val:
break
anciens_arrivees.append(val)
i += 1
st.session_state["analyse_noeuds_arrivee"] = anciens_arrivees
st.multiselect(
str(_("pages.analyse.filter_end_nodes")),
sorted(arrivee_nodes),
key="analyse_noeuds_arrivee"
)
arrivees_selection = st.session_state["analyse_noeuds_arrivee"]
supprime_champ_statut("pages.analyse.filter_end_nodes")
if arrivees_selection:
for i, val in enumerate(arrivees_selection):
maj_champ_statut(f"pages.analyse.filter_end_nodes.{i}", val)
departs_selection = departs_selection if departs_selection else None
arrivees_selection = arrivees_selection if arrivees_selection else None
return departs_selection, arrivees_selection
def configurer_filtres_vulnerabilite() -> Tuple[bool, bool, bool, str, bool, str]:
"""
Interface pour configurer les filtres de vulnérabilité.
Returns:
Tuple[bool, bool, bool, str, bool, str]: Un tuple contenant :
- La possibilité de filtrer les ICS
- La possibilité de filtrer les IV C
- La possibilité de filtrer les IH H
- Le type d'application pour les IH H (pays ou acteur)
- La possibilité de filtrer les IS G
- La logique de filtrage (ou ou and)
"""
st.markdown("---")
st.markdown(f"## {str(_('pages.analyse.vulnerability_filters'))}")
filtrer_ics = st.checkbox(str(_("pages.analyse.filter_ics")),
key="analyse_filtrer_ics")
filtrer_ivc = st.checkbox(str(_("pages.analyse.filter_ivc")),
key="analyse_filtrer_ivc")
filtrer_ihh = st.checkbox(str(_("pages.analyse.filter_ihh")),
key="analyse_filtrer_ihh")
def init_checkbox(key, champ):
if key not in st.session_state:
val = get_champ_statut(champ)
st.session_state[key] = val.lower() == "true"
def init_radio(key, champ, options, default):
if key not in st.session_state:
val = get_champ_statut(champ)
st.session_state[key] = val if val in options else default
# Initialiser les valeurs si F5
init_checkbox("analyse_filtrer_ics", "pages.analyse.filter_ics")
init_checkbox("analyse_filtrer_ivc", "pages.analyse.filter_ivc")
init_checkbox("analyse_filtrer_ihh", "pages.analyse.filter_ihh")
init_checkbox("analyse_filtrer_isg", "pages.analyse.filter_isg")
init_radio("analyse_ihh_type", "pages.analyse.apply_ihh_filter", ["Pays", "Acteurs"], "Pays")
init_radio("analyse_logique_filtrage", "pages.analyse.filter_logic", ["OU", "ET"], "OU")
filtrer_ics = st.checkbox(str(_("pages.analyse.filter_ics")), key="analyse_filtrer_ics")
filtrer_ivc = st.checkbox(str(_("pages.analyse.filter_ivc")), key="analyse_filtrer_ivc")
filtrer_ihh = st.checkbox(str(_("pages.analyse.filter_ihh")), key="analyse_filtrer_ihh")
ihh_type = "Pays"
if filtrer_ihh:
ihh_type = st.radio(str(_("pages.analyse.apply_ihh_filter")),
[str(_("pages.analyse.countries")), str(_("pages.analyse.actors"))],
horizontal=True,
key="analyse_ihh_type")
ihh_type = st.radio(
str(_("pages.analyse.apply_ihh_filter")),
[str(_("pages.analyse.countries")), str(_("pages.analyse.actors"))],
horizontal=True,
key="analyse_ihh_type"
)
filtrer_isg = st.checkbox(str(_("pages.analyse.filter_isg")),
key="analyse_filtrer_isg")
logique_filtrage = st.radio(str(_("pages.analyse.filter_logic")),
[str(_("pages.analyse.or")), str(_("pages.analyse.and"))],
horizontal=True,
key="analyse_logique_filtrage")
filtrer_isg = st.checkbox(str(_("pages.analyse.filter_isg")), key="analyse_filtrer_isg")
logique_options = ["OU", "ET"]
logique_labels = {
"OU": str(_("pages.analyse.or")),
"ET": str(_("pages.analyse.and"))
}
logique_filtrage = st.radio(
str(_("pages.analyse.filter_logic")),
options=logique_options,
format_func=lambda x: logique_labels.get(x, x),
horizontal=True,
key="analyse_logique_filtrage"
)
# Sauvegarde de l'état
maj_champ_statut("pages.analyse.filter_ics", str(filtrer_ics))
maj_champ_statut("pages.analyse.filter_ivc", str(filtrer_ivc))
maj_champ_statut("pages.analyse.filter_ihh", str(filtrer_ihh))
maj_champ_statut("pages.analyse.apply_ihh_filter", ihh_type)
maj_champ_statut("pages.analyse.filter_isg", str(filtrer_isg))
maj_champ_statut("pages.analyse.filter_logic", logique_filtrage)
return filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage
def interface_analyse(
G_temp: nx.DiGraph,
) -> None:
"""
Interface utilisateur pour l'analyse des graphes.
def interface_analyse(G_temp):
st.markdown(f"# {str(_('pages.analyse.title'))}")
with st.expander(str(_("pages.analyse.help")), expanded=False):
st.markdown("\n".join(_("pages.analyse.help_content")))
Args:
G_temp (nx.DiGraph): Le graphe NetworkX à analyser.
"""
titre = f"# {str(_('pages.analyse.title'))}"
maj_champ_statut("pages.analyse.title", titre)
st.markdown(titre)
html_expander(f"{str(_('pages.analyse.help'))}", content="\n".join(_("pages.analyse.help_content")), open_by_default=False, details_class="details_introduction")
st.markdown("---")
try:
@ -148,7 +307,7 @@ def interface_analyse(G_temp):
# Lancement de l'analyse
st.markdown("---")
if st.button(str(_("pages.analyse.run_analysis")), type="primary", key="analyse_lancer"):
if st.button(str(_("pages.analyse.run_analysis")), type="primary", key="analyse_lancer", icon=":material/graph_4:"):
afficher_sankey(
G_temp,
niveau_depart=niveau_depart,

View File

@ -1,3 +1,4 @@
from typing import Dict, List, Tuple, Optional, Set
import streamlit as st
from networkx.drawing.nx_agraph import write_dot
import pandas as pd
@ -25,8 +26,18 @@ niveau_labels = {
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
def extraire_niveaux(G):
"""Extrait les niveaux des nœuds du graphe"""
def extraire_niveaux(
G: nx.DiGraph,
) -> Dict[str, int]:
"""
Extrait les niveaux des nœuds du graphe.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
Dict[str, int]: Un dictionnaire associant chaque nœud à son niveau.
"""
niveaux = {}
for node, attrs in G.nodes(data=True):
niveau_str = attrs.get("niveau")
@ -37,17 +48,52 @@ def extraire_niveaux(G):
logging.warning(f"Niveau non entier pour le noeud {node}: {niveau_str}")
return niveaux
def extraire_criticite(G, u, v):
"""Extrait la criticité d'un lien entre deux nœuds"""
def extraire_ics(
G: nx.DiGraph,
u: str,
v: str,
) -> float:
"""
Extrait la criticité d'un lien entre deux nœuds.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
u (str): L'ID du premier nœud.
v (str): L'ID du second nœud.
Returns:
float: La valeur de criticité entre les deux nœuds, ou 0 si impossible à extraire.
"""
data = G.get_edge_data(u, v)
if not data:
return 0
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
return float(data[0].get("criticite", 0))
return float(data.get("criticite", 0))
return float(data[0].get("ics", 0))
return float(data.get("ics", 0))
def extraire_chemins_selon_criteres(
G: nx.DiGraph,
niveaux: Dict[str, int],
niveau_depart: int,
noeuds_depart: Optional[List[str]],
noeuds_arrivee: Optional[List[str]],
minerais: Optional[List[str]],
) -> List[Tuple[str, ...]]:
"""
Extrait les chemins selon les critères spécifiés.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
noeuds_depart (Optional[List[str]]): Les nœuds de départ si sélectionnés.
noeuds_arrivee (Optional[List[str]]): Les nœuds d'arrivée si sélectionnés.
minerais (Optional[List[str]]): Les minerais si sélectionnés.
Returns:
List[Tuple[str, ...]]: Liste des chemins valides selon les critères spécifiés.
"""
def extraire_chemins_selon_criteres(G, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais):
"""Extrait les chemins selon les critères spécifiés"""
chemins = []
if noeuds_depart and noeuds_arrivee:
for nd in noeuds_depart:
@ -70,8 +116,24 @@ def extraire_chemins_selon_criteres(G, niveaux, niveau_depart, noeuds_depart, no
return chemins
def verifier_critere_ihh(G, chemin, niveaux, ihh_type):
"""Vérifie si un chemin respecte le critère IHH (concentration géographique ou industrielle)"""
def verifier_critere_ihh(
G: nx.DiGraph,
chemin: Tuple[str, ...],
niveaux: Dict[str, int],
ihh_type: str,
) -> bool:
"""
Vérifie si un chemin respecte le critère IHH (concentration géographique ou industrielle).
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
chemin (Tuple[str, ...]): Un chemin validé selon les critères antérieurs.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
ihh_type (str): Le type d'application pour les IHH (pays ou acteur).
Returns:
bool: True si le chemin respecte le critère IHH, False sinon.
"""
ihh_field = "ihh_pays" if ihh_type == "Pays" else "ihh_acteurs"
for i in range(len(chemin) - 1):
u, v = chemin[i], chemin[i + 1]
@ -84,8 +146,22 @@ def verifier_critere_ihh(G, chemin, niveaux, ihh_type):
return True
return False
def verifier_critere_ivc(G, chemin, niveaux):
"""Vérifie si un chemin respecte le critère IVC (criticité par rapport à la concurrence sectorielle)"""
def verifier_critere_ivc(
G: nx.DiGraph,
chemin: Tuple[str, ...],
niveaux: Dict[str, int],
) -> bool:
"""
Vérifie si un chemin respecte le critère IVC (criticité par rapport à la concurrence sectorielle).
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
chemin (Tuple[str, ...]): Un chemin validé selon les critères antérieurs.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
Returns:
bool: True si le chemin respecte le critère IVC, False sinon.
"""
for i in range(len(chemin) - 1):
u = chemin[i]
niveau_u = niveaux.get(u)
@ -93,8 +169,22 @@ def verifier_critere_ivc(G, chemin, niveaux):
return True
return False
def verifier_critere_ics(G, chemin, niveaux):
"""Vérifie si un chemin respecte le critère ICS (criticité d'un minerai pour un composant)"""
def verifier_critere_ics(
G: nx.DiGraph,
chemin: Tuple[str, ...],
niveaux: Dict[str, int],
) -> bool:
"""
Vérifie si un chemin respecte le critère ICS (criticité d'un minerai pour un composant).
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
chemin (Tuple[str, ...]): Un chemin validé selon les critères antérieurs.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
Returns:
bool: True si le chemin respecte le critère ICS, False sinon.
"""
for i in range(len(chemin) - 1):
u, v = chemin[i], chemin[i + 1]
niveau_u = niveaux.get(u)
@ -102,12 +192,26 @@ def verifier_critere_ics(G, chemin, niveaux):
if ((niveau_u == 1 and niveau_v == 2) or
(niveau_u == 1001 and niveau_v == 1002) or
(niveau_u == 10 and niveau_v in (1000, 1001))) and extraire_criticite(G, u, v) > 0.66:
(niveau_u == 10 and niveau_v in (1000, 1001))) and extraire_ics(G, u, v) > 0.66:
return True
return False
def verifier_critere_isg(G, chemin, niveaux):
"""Vérifie si un chemin contient un pays instable (ISG ≥ 60)"""
def verifier_critere_isg(
G: nx.DiGraph,
chemin: Tuple[str, ...],
niveaux: Dict[str, int],
) -> bool:
"""
Vérifie si un chemin contient un pays instable (ISG 60).
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
chemin (Tuple[str, ...]): Un chemin validé selon les critères antérieurs.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
Returns:
bool: True si le chemin contient un pays instable, False sinon.
"""
for i in range(len(chemin) - 1):
u, v = chemin[i], chemin[i + 1]
@ -120,8 +224,26 @@ def verifier_critere_isg(G, chemin, niveaux):
return True
return False
def extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux):
"""Extrait les liens des chemins en respectant les niveaux"""
def extraire_liens_filtres(
chemins: List[Tuple[str, ...]],
niveaux: Dict[str, int],
niveau_depart: int,
niveau_arrivee: int,
niveaux_speciaux: List[int]
) -> Set[Tuple[str, str]]:
"""
Extrait les liens des chemins en respectant les niveaux.
Args:
chemins (List[Tuple[str, ...]]): Liste initiale des chemins validés.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
niveau_arrivee (int): Le niveau d'arrivée sélectionné.
niveaux_speciaux (List[int]): Les niveaux spéciaux à inclure dans l'extraction.
Returns:
Set[Tuple[str, str]]: Ensemble des paires de nœuds liés après filtrage.
"""
liens = set()
for chemin in chemins:
for i in range(len(chemin) - 1):
@ -135,9 +257,40 @@ def extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, nive
liens.add((u, v))
return liens
def filtrer_chemins_par_criteres(G, chemins, niveaux, niveau_depart, niveau_arrivee,
filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage):
"""Filtre les chemins selon les critères de vulnérabilité"""
def filtrer_chemins_par_criteres(
G: nx.DiGraph,
chemins: List[Tuple[str, ...]],
niveaux: Dict[str, int],
niveau_depart: int,
niveau_arrivee: int,
filtrer_ics: bool,
filtrer_ivc: bool,
filtrer_ihh: bool,
ihh_type: str,
filtrer_isg: bool,
logique_filtrage: str,
) -> Tuple[Set[Tuple[str, str]], Set[Tuple[str, ...]]]:
"""
Filtre les chemins selon les critères de vulnérabilité.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
chemins (List[Tuple[str, ...]]): Liste initiale des chemins validés.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
niveau_arrivee (int): Le niveau d'arrivée sélectionné.
filtrer_ics (bool): Si le filtre ICS doit être appliqué.
filtrer_ivc (bool): Si le filtre IVC doit être appliqué.
filtrer_ihh (bool): Si le filtre IHH doit être appliqué.
ihh_type (str): Le type d'application pour les IHH (pays ou acteur).
filtrer_isg (bool): Si le filtre ISG doit être appliqué.
logique_filtrage (str): La logique de filtrage (ET OU).
Returns:
Tuple[Set[Tuple[str, str]], Set[Tuple[str, ...]]]: Un tuple contenant :
- Les liens validés
- Les chemins filtrés finaux
"""
niveaux_speciaux = [1000, 1001, 1002, 1010, 1011, 1012]
# Extraction des liens sans filtrage
@ -153,7 +306,7 @@ def filtrer_chemins_par_criteres(G, chemins, niveaux, niveau_depart, niveau_arri
# Vérification des critères pour ce chemin
has_ihh = filtrer_ihh and verifier_critere_ihh(G, chemin, niveaux, ihh_type)
has_ivc = filtrer_ivc and verifier_critere_ivc(G, chemin, niveaux)
has_criticite = filtrer_ics and verifier_critere_ics(G, chemin, niveaux)
has_ics = filtrer_ics and verifier_critere_ics(G, chemin, niveaux)
has_isg_critique = filtrer_isg and verifier_critere_isg(G, chemin, niveaux)
# Appliquer la logique de filtrage
@ -161,12 +314,12 @@ def filtrer_chemins_par_criteres(G, chemins, niveaux, niveau_depart, niveau_arri
keep = True
if filtrer_ihh: keep = keep and has_ihh
if filtrer_ivc: keep = keep and has_ivc
if filtrer_ics: keep = keep and has_criticite
if filtrer_ics: keep = keep and has_ics
if filtrer_isg: keep = keep and has_isg_critique
if keep:
chemins_filtres.add(tuple(chemin))
elif logique_filtrage == "OU":
if has_ihh or has_ivc or has_criticite or has_isg_critique:
if has_ihh or has_ivc or has_ics or has_isg_critique:
chemins_filtres.add(tuple(chemin))
# Extraction des liens après filtrage
@ -176,8 +329,18 @@ def filtrer_chemins_par_criteres(G, chemins, niveaux, niveau_depart, niveau_arri
return liens_filtres, chemins_filtres
def couleur_criticite(p):
"""Retourne la couleur en fonction du niveau de criticité"""
def couleur_ics(
p: float
) -> str:
"""
Retourne la couleur en fonction du niveau de criticité.
Args:
p (float): Valeur de criticité (entre 0 et 1).
Returns:
str: Couleur représentative du niveau de criticité.
"""
if p <= 0.33:
return "darkgreen"
elif p <= 0.66:
@ -185,8 +348,22 @@ def couleur_criticite(p):
else:
return "darkred"
def edge_info(G, u, v):
"""Génère l'info-bulle pour un lien"""
def edge_info(
G: nx.DiGraph,
u: str,
v: str
) -> str:
"""
Génère l'info-bulle pour un lien.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
u (str): L'ID du premier nœud.
v (str): L'ID du second nœud.
Returns:
str: Texte à afficher dans l'info-bulle pour le lien spécifié.
"""
# Liste d'attributs à exclure des infobulles des liens
attributs_exclus = ["poids", "color", "fontcolor"]
@ -198,16 +375,38 @@ def edge_info(G, u, v):
base = [f"{k}: {v}" for k, v in data.items() if k not in attributs_exclus]
return f"{str(_('pages.analyse.sankey.relation'))} : {u}{v}<br>" + "<br>".join(base)
def preparer_donnees_sankey(G, liens_chemins, niveaux, chemins):
"""Prépare les données pour le graphique Sankey"""
def preparer_donnees_sankey(
G: nx.DiGraph,
liens_chemins: Set[Tuple[str, str]],
niveaux: Dict[str, int],
chemins: List[Tuple[str, ...]]
) -> Tuple[pd.DataFrame, List[str], List[List[str]], List[str], Dict[str, int]]:
"""
Prépare les données pour le graphique Sankey.
Args:
G (Any): Le graphe NetworkX contenant les données des produits.
liens_chemins (Set[Tuple[str, str]]): Ensemble des paires de nœuds liés.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
chemins (List[Tuple[str, ...]]): Liste initiale des chemins validés.
Returns:
Tuple[pd.DataFrame, List[str], List[List[str]], List[str], Dict[str, int]]:
Un tuple contenant :
- Le DataFrame lié aux chemins filtrés
- La liste triée et ordonnée des nœuds
- Les listes de données personnalisées pour les nœuds
- Les donnnées personnalisées pour les liens
- Le dictionnaire associant chaque nœud à son index
"""
# Liste d'attributs à exclure des infobulles des nœuds
node_attributs_exclus = ["fillcolor", "niveau"]
df_liens = pd.DataFrame(list(liens_chemins), columns=["source", "target"])
df_liens = df_liens.groupby(["source", "target"]).size().reset_index(name="value")
df_liens["criticite"] = df_liens.apply(
lambda row: extraire_criticite(G, row["source"], row["target"]), axis=1)
df_liens["ics"] = df_liens.apply(
lambda row: extraire_ics(G, row["source"], row["target"]), axis=1)
df_liens["value"] = 0.1
# Ne garder que les nœuds effectivement connectés
@ -221,7 +420,7 @@ def preparer_donnees_sankey(G, liens_chemins, niveaux, chemins):
noeuds_utilises.add(n)
df_liens["color"] = df_liens.apply(
lambda row: couleur_criticite(row["criticite"]) if row["criticite"] > 0 else "white",
lambda row: couleur_ics(row["ics"]) if row["ics"] > 0 else "white",
axis=1
)
@ -251,8 +450,30 @@ def preparer_donnees_sankey(G, liens_chemins, niveaux, chemins):
return df_liens, sorted_nodes, customdata, link_customdata, node_indices
def creer_graphique_sankey(G, niveaux, df_liens, sorted_nodes, customdata, link_customdata, node_indices):
"""Crée et retourne le graphique Sankey"""
def creer_graphique_sankey(
G: nx.DiGraph,
niveaux: Dict[str, int],
df_liens: pd.DataFrame,
sorted_nodes: List[str],
customdata: List[str],
link_customdata: List[str],
node_indices: Dict[str, int],
) -> go.Figure:
"""
Crée et retourne le graphique Sankey.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
df_liens (pd.DataFrame): Données du DataFrame lié aux chemins filtrés.
sorted_nodes (List[str]): Liste triée et ordonnée des nœuds.
customdata (List[str]): Listes de données personnalisées pour les nœuds.
link_customdata (List[str]): Données personnalisées pour les liens.
node_indices (Dict[str, int]): Dictionnaire associant chaque nœud à son index.
Returns:
go.Figure: Le graphique Sankey final.
"""
sources = df_liens["source"].map(node_indices).tolist()
targets = df_liens["target"].map(node_indices).tolist()
values = df_liens["value"].tolist()
@ -291,9 +512,22 @@ def creer_graphique_sankey(G, niveaux, df_liens, sorted_nodes, customdata, link_
return fig
def exporter_graphe_filtre(G, liens_chemins):
"""Gère l'export du graphe filtré au format DOT"""
if not st.session_state.get("logged_in", False) or not liens_chemins:
def exporter_graphe_filtre(
G: nx.DiGraph,
liens_chemins: Set[Tuple[str, str]]
) -> None:
"""
Gère l'export du graphe filtré au format DOT.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
liens_chemins (Set[Tuple[str, str]]): Ensemble des paires de nœuds liés.
Returns:
None
"""
from utils.persistance import get_champ_statut
if get_champ_statut("login") == "" or not liens_chemins:
return
G_export = nx.DiGraph()
@ -321,13 +555,30 @@ def exporter_graphe_filtre(G, liens_chemins):
)
def afficher_sankey(
G,
niveau_depart, niveau_arrivee,
noeuds_depart=None, noeuds_arrivee=None,
G: nx.DiGraph,
niveau_depart: int, niveau_arrivee: int,
noeuds_depart: Optional[List[str]] = None, noeuds_arrivee: Optional[List[str]] = None,
minerais=None,
filtrer_ics=False, filtrer_ivc=False,
filtrer_ihh=False, ihh_type="Pays", filtrer_isg=False,
logique_filtrage="OU"):
filtrer_ics: bool = False, filtrer_ivc: bool = False,
filtrer_ihh: bool = False, ihh_type: str = "Pays", filtrer_isg: bool = False,
logique_filtrage: str = "OU") -> None:
"""
Fonction principale qui s'occupe de la création et de l'affichage du graphique Sankey.
Args:
G: Le graphe NetworkX contenant les données des produits.
niveau_depart, niveau_arrivee: Les niveaux initiaux pour le filtrage.
noeuds_depart, noeuds_arrivee: Les nœuds initiaux pour le filtrage.
minerais: La liste des minerais à inclure dans le filtrage.
filtrer_ics, filtrer_ivc: Les booléens pour le filtrage ICS et IVC.
filtrer_ihh: Le booléen pour le filtrage IHH.
ihh_type: Le type d'application pour les IHH (Pays ou Acteur).
filtrer_isg: Le booléen pour le filtrage ISG.
logique_filtrage: La logique de filtrage à appliquer (ET OU).
Returns:
go.Figure
"""
# Étape 1 : Extraction des niveaux des nœuds
niveaux = extraire_niveaux(G)

View File

@ -1,2 +1,4 @@
# __init__.py app/fiches
from .interface import interface_fiches
__all__ = ["interface_fiches"]

View File

@ -1,24 +1,47 @@
"""
Module de génération des fiches pour l'application.
Fonctions principales :
1. `remplacer_latex_par_mathml`
2. `markdown_to_html_rgaa`
3. `rendu_html`
4. `generer_fiche`
Toutes ces fonctions gèrent la conversion et le rendu de contenu Markdown
vers du HTML structuré avec des mathématiques, respectant les règles RGAA.
"""
import re
import os
import yaml
import markdown
from bs4 import BeautifulSoup
from latex2mathml.converter import convert as latex_to_mathml
from .utils.fiche_utils import render_fiche_markdown
import pypandoc
import streamlit as st
from .utils.dynamic import (
from app.fiches.utils import (
build_dynamic_sections,
build_ivc_sections,
build_ihh_sections,
build_isg_sections,
build_production_sections,
build_minerai_sections
build_minerai_sections,
render_fiche_markdown
)
# === Fonctions de transformation ===
def remplacer_latex_par_mathml(markdown_text):
def remplacer_latex_par_mathml(markdown_text: str) -> str:
"""
Remplace les formules LaTeX par des blocs MathML.
Args:
markdown_text (str): Texte Markdown contenant du LaTeX.
Returns:
str: Le même texte avec les formules LaTeX converties en MathML.
"""
def remplacer_bloc_display(match):
formule_latex = match.group(1).strip()
try:
@ -39,7 +62,17 @@ def remplacer_latex_par_mathml(markdown_text):
markdown_text = re.sub(r"(?<!\$)\$(.+?)\$(?!\$)", remplacer_bloc_inline, markdown_text, flags=re.DOTALL)
return markdown_text
def markdown_to_html_rgaa(markdown_text, caption_text=None):
def markdown_to_html_rgaa(markdown_text: str, caption_text: str|None) -> str:
"""
Convertit un texte Markdown en HTML structuré accessible.
Args:
markdown_text (str): Texte Markdown à convertir.
caption_text (str, optional): Titre du tableau si applicable.
Returns:
str: Le HTML structuré avec des attributs de contraintes ARIA.
"""
html = markdown.markdown(markdown_text, extensions=['tables'])
soup = BeautifulSoup(html, "html.parser")
for i, table in enumerate(soup.find_all("table"), start=1):
@ -53,7 +86,16 @@ def markdown_to_html_rgaa(markdown_text, caption_text=None):
th["scope"] = "col"
return str(soup)
def rendu_html(contenu_md):
def rendu_html(contenu_md: str) -> list[str]:
"""
Rend le contenu Markdown en HTML avec une structure spécifique.
Args:
contenu_md (str): Texte Markdown à formater.
Returns:
list[str]: Liste d'étapes de construction du HTML final.
"""
lignes = contenu_md.split('\n')
sections_n1 = []
section_n1_actuelle = {"titre": None, "intro": [], "sections_n2": {}}
@ -84,7 +126,7 @@ def rendu_html(contenu_md):
html_output.append(f"<h2>{bloc['titre']}</h2>")
if bloc["intro"]:
intro_md = remplacer_latex_par_mathml("\n".join(bloc["intro"]))
html_intro = markdown_to_html_rgaa(intro_md)
html_intro = markdown_to_html_rgaa(intro_md, None)
html_output.append(html_intro)
for sous_titre, contenu in bloc["sections_n2"].items():
contenu_md = remplacer_latex_par_mathml("\n".join(contenu))
@ -95,7 +137,25 @@ def rendu_html(contenu_md):
return html_output
def generer_fiche(md_source, dossier, nom_fichier, seuils):
def generer_fiche(md_source: str, dossier: str, nom_fichier: str, seuils: dict) -> str:
"""
Génère un document PDF et son HTML correspondant pour une fiche.
Args:
md_source (str): Texte Markdown source contenant la fiche.
dossier (str): Dossier/rubrique de destination.
nom_fichier (str): Nom du fichier (sans extension).
seuils (dict): Valeurs de seuils pour l'analyse.
Returns:
str: Chemin absolu vers le fichier HTML généré.
Notes:
Cette fonction :
- Convertit et formate les données Markdown.
- Génère un document PDF sous format XeLaTeX.
- Crée un document HTML accessible avec des mathématiques.
"""
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
context = yaml.safe_load(front_match.group(1)) if front_match else {}

View File

@ -2,25 +2,24 @@
import streamlit as st
import requests
import os
import pathlib
from utils.translations import _
from .utils.tickets.display import afficher_tickets_par_fiche
from .utils.tickets.creation import formulaire_creation_ticket_dynamique
from .utils.tickets.core import rechercher_tickets_gitea
from config import GITEA_TOKEN, GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV, FICHES_CRITICITE
from utils.gitea import charger_arborescence_fiches
from utils.widgets import html_expander
from .utils.fiche_utils import load_seuils, doit_regenerer_fiche
from app.fiches.utils import (
afficher_tickets_par_fiche,
formulaire_creation_ticket_dynamique,
rechercher_tickets_gitea,
load_seuils,
doit_regenerer_fiche
)
from app.fiches.generer import generer_fiche
from .generer import generer_fiche
def interface_fiches():
def interface_fiches() -> None:
st.markdown(f"# {str(_('pages.fiches.title'))}")
with st.expander(str(_("pages.fiches.help")), expanded=False):
st.markdown("\n".join(_("pages.fiches.help_content")))
html_expander(f"{str(_('pages.fiches.help'))}", content="\n".join(_("pages.fiches.help_content")), open_by_default=False, details_class="details_introduction")
st.markdown("---")
if "fiches_arbo" not in st.session_state:
@ -32,19 +31,45 @@ def interface_fiches():
return
dossiers = sorted(arbo.keys(), key=lambda x: x.lower())
dossier_choisi = st.selectbox(
str(_("pages.fiches.choose_category",)),
[str(_("pages.fiches.select_folder"))] + dossiers
if "dossier_choisi" not in st.session_state or st.session_state["dossier_choisi"] not in dossiers:
st.session_state["dossier_choisi"] = str(_("pages.fiches.select_folder"))
try:
index_dossier = [str(_("pages.fiches.select_folder"))] + dossiers
idx = index_dossier.index(st.session_state["dossier_choisi"])
except ValueError:
idx = 0
st.session_state["dossier_choisi"] = st.selectbox(
str(_("pages.fiches.choose_category")),
[str(_("pages.fiches.select_folder"))] + dossiers,
index=idx
)
dossier_choisi = st.session_state["dossier_choisi"]
if dossier_choisi and dossier_choisi != str(_("pages.fiches.select_folder")):
fiches = arbo.get(dossier_choisi, [])
noms_fiches = [f['nom'] for f in fiches]
fiche_choisie = st.selectbox(
if "fiche_choisie" not in st.session_state or st.session_state["fiche_choisie"] not in noms_fiches:
st.session_state["fiche_choisie"] = str(_("pages.fiches.select_file"))
try:
index_fiche = [str(_("pages.fiches.select_file"))] + noms_fiches
idx_fiche = index_fiche.index(st.session_state["fiche_choisie"])
except ValueError:
idx_fiche = 0
st.session_state["fiche_choisie"] = st.selectbox(
str(_("pages.fiches.choose_file")),
[str(_("pages.fiches.select_file"))] + noms_fiches
[str(_("pages.fiches.select_file"))] + noms_fiches,
index=idx_fiche
)
fiche_choisie = st.session_state["fiche_choisie"]
if fiche_choisie and fiche_choisie != str(_("pages.fiches.select_file")):
fiche_info = next((f for f in fiches if f["nom"] == fiche_choisie), None)
if fiche_info:
@ -79,11 +104,11 @@ def interface_fiches():
with open(html_path, "r", encoding="utf-8") as f:
st.markdown(f.read(), unsafe_allow_html=True)
if st.session_state.get("logged_in", False):
from utils.persistance import get_champ_statut
if not get_champ_statut("login") == "":
pdf_name = nom_fiche + ".pdf"
pdf_path = os.path.join("static", "Fiches", dossier_choisi, pdf_name)
# Bouton de téléchargement du PDF
if os.path.exists(pdf_path):
with open(pdf_path, "rb") as pdf_file:
st.download_button(
@ -102,4 +127,4 @@ def interface_fiches():
formulaire_creation_ticket_dynamique(fiche_choisie)
except Exception as e:
st.error(f"{str(_('pages.fiches.loading_error', 'Erreur lors du chargement de la fiche :'))} {e}")
st.error(f"{str(_('pages.fiches.loading_error'))} {e}")

View File

@ -0,0 +1,31 @@
from .tickets.display import afficher_tickets_par_fiche
from .tickets.creation import formulaire_creation_ticket_dynamique
from .tickets.core import rechercher_tickets_gitea
from .fiche_utils import (
load_seuils,
doit_regenerer_fiche
)
from .dynamic import (
build_dynamic_sections,
build_ivc_sections,
build_ihh_sections,
build_isg_sections,
build_production_sections,
build_minerai_sections
)
from .fiche_utils import render_fiche_markdown
__all__ = [
"afficher_tickets_par_fiche",
"formulaire_creation_ticket_dynamique",
"rechercher_tickets_gitea",
"load_seuils",
"doit_regenerer_fiche",
"build_dynamic_sections",
"build_ivc_sections",
"build_ihh_sections",
"build_isg_sections",
"build_production_sections",
"build_minerai_sections",
"render_fiche_markdown"
]

View File

@ -7,7 +7,22 @@ import streamlit as st
from config import FICHES_CRITICITE
def build_production_sections(md: str) -> str:
"""
Procédure pour construire et remplacer les sections des fiches de production.
Cette fonction permet d'extraire les données du markdown, organiser
les informations sur les pays d'implantation et acteurs, puis générer
un tableau structuré dans l'intervention. Le code prend en charge
deux types de fiches : fabrication et assemblage.
Args:
md (str): Fichier Markdown à traiter contenant la structure YAML des sections.
Returns:
str: Markdown modifié avec les tableaux construits selon le type de fiche.
"""
schema = None
type_fiche = None
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
try:
@ -23,10 +38,10 @@ def build_production_sections(md: str) -> str:
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
if not yaml_block:
return md
# Capture le bloc YAML complet pour le supprimer plus tard
yaml_block_full = yaml_block.group(0)
try:
yaml_data = yaml.safe_load(yaml_block.group(1))
except Exception as e:
@ -133,7 +148,7 @@ def build_production_sections(md: str) -> str:
st.warning(f"Aucune section IHH trouvée pour le schéma {schema} dans la fiche technique IHH.")
except Exception as e:
st.error(f"Erreur lors de la lecture/traitement de la fiche IHH: {e}")
# Supprimer le bloc YAML du markdown final
md_modifie = md_modifie.replace(yaml_block_full, "")

View File

@ -65,6 +65,21 @@ def _synth(df: pd.DataFrame) -> str:
return "\n".join(lignes)
def build_dynamic_sections(md_raw: str) -> str:
"""
Procédure pour construire et remplacer les sections dynamiques dans les fiches d'analyse produit (ICS).
Cette fonction permet de :
1. Extraire les données structurées en YAML des blocs du markdown.
2. Générer un tableau pivotant les données sur la criticité et faisabilité technique.
3. Produire une synthèse finale avec l'analyse critique par composant.
Args:
md (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
Returns:
str: Le markdown enrichi des tableaux de donnée analysés, ou le contenu original inchangé si aucun bloc structuré n'est trouvé.
"""
md_raw = _normalize_unicode(md_raw)
df = _pairs_dataframe(md_raw)
if df.empty:

View File

@ -51,20 +51,20 @@ def _generer_tableau_produits(produits: dict) -> str:
"""Génère un tableau markdown pour les produits."""
if not produits:
return ""
resultat = ["\n\n## Assemblage des produits\n"]
lignes = [
"| Produit | Assemblage IHH Pays | Assemblage IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for produit, data in sorted(produits.items()):
pastille_1 = pastille("IHH", data['assemblage_ihh_pays'])
pastille_2 = pastille("IHH", data['assemblage_ihh_acteurs'])
lignes.append(
f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
)
resultat.append("\n".join(lignes))
return "\n".join(resultat)
@ -73,20 +73,20 @@ def _generer_tableau_composants(composants: dict) -> str:
"""Génère un tableau markdown pour les composants."""
if not composants:
return ""
resultat = ["\n\n## Fabrication des composants\n"]
lignes = [
"| Composant | Fabrication IHH Pays | Fabrication IHH Acteurs |",
"| :-- | :--: | :--: |"
]
for composant, data in sorted(composants.items()):
pastille_1 = pastille("IHH", data['fabrication_ihh_pays'])
pastille_2 = pastille("IHH", data['fabrication_ihh_acteurs'])
lignes.append(
f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
)
resultat.append("\n".join(lignes))
return "\n".join(resultat)
@ -95,13 +95,13 @@ def _generer_tableau_minerais(minerais: dict) -> str:
"""Génère un tableau markdown pour les minerais."""
if not minerais:
return ""
resultat = ["\n\n## Opérations sur les minerais\n"]
lignes = [
"| Minerai | Extraction IHH Pays | Extraction IHH Acteurs | Réserves IHH Pays | Traitement IHH Pays | Traitement IHH Acteurs |",
"| :-- | :--: | :--: | :--: | :--: | :--: |"
]
for minerai, data in sorted(minerais.items()):
pastille_1 = pastille("IHH", data['extraction_ihh_pays'])
pastille_2 = pastille("IHH", data['extraction_ihh_acteurs'])
@ -112,7 +112,7 @@ def _generer_tableau_minerais(minerais: dict) -> str:
f"| {minerai} | {pastille_1} {data['extraction_ihh_pays']} | {pastille_2} {data['extraction_ihh_acteurs']} | "
f"{pastille_3} {data['reserves_ihh_pays']} | {pastille_4} {data['traitement_ihh_pays']} | {pastille_5} {data['traitement_ihh_acteurs']} |"
)
resultat.append("\n".join(lignes))
return "\n".join(resultat)
@ -121,23 +121,37 @@ def _synth_ihh(operations: list[dict]) -> str:
"""Génère des tableaux de synthèse pour les indices HHI à partir des opérations."""
# Extraction et organisation des données
data_by_item = _extraire_donnees_operations(operations)
# Catégorisation des items
produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
# Génération des tableaux pour chaque catégorie
tableaux = [
_generer_tableau_produits(produits),
_generer_tableau_composants(composants),
_generer_tableau_minerais(minerais)
]
# Assemblage du résultat final
return "\n".join([t for t in tableaux if t])
def build_ihh_sections(md: str) -> str:
"""
Fonction principale pour générer les sections dynamiques dans le markdown, spécifiquement dédiée à l'analyse des indices IHH.
La fonction gère les différents types de données présents dans les fiches, notamment :
- Les opérations d'extraction et de traitement du minerai
- L'assemblage des produits finaux
- La fabrication des composants intermédiaires
Args:
md (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
Returns:
str: Le markdown enrichi avec les tableaux de donnée analysés, ou le contenu original inchangé si aucun bloc structuré n'est trouvé.
"""
segments = []
operations = []
intro = None

View File

@ -25,6 +25,20 @@ def _synth_isg(md: str) -> str:
return "\n".join(lignes)
def build_isg_sections(md: str) -> str:
"""
Fonction principale pour générer les sections dynamiques dans le markdown, spécifiquement dédiée à l'analyse des indices ISG.
La fonction gère :
- La structure YAML front-matter pour vérifier si c'est bien un tableau ISG
- L'extraction et tri du pays selon la valeur ISG
- Le formatage des données de WGI, FSI, NDGain et ISG dans le tableau final
Args:
md (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
Returns:
str: Le markdown enrichi avec le tableau de donnée analysé pour l'indice ISG, ou le contenu original inchangé si aucun bloc structuré n'est trouvé.
"""
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
if front_match:
front_matter = yaml.safe_load(front_match.group(1))

View File

@ -31,7 +31,20 @@ def _ivc_segments(md: str):
yield None, md[pos:] # reste éventuel
def build_ivc_sections(md: str) -> str:
"""Remplace les blocs YAML minerai + segment avec rendu Jinja2, conserve l'intro."""
"""
Fonction principale pour générer les sections dynamiques dans le markdown, spécifiquement dédiée à l'analyse des Indices de Vulnérabilité Complète (IVC).
La fonction gère :
- L'extraction et tri des données IVC pour chaque minerai
- Le formatage des données d'IVC, Vulnérabilité, etc. dans le tableau final
- La génération du tableau synthétique pour l'analyse globale
Args:
md (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
Returns:
str: Le markdown enrichi avec le tableau de donnée analysé pour l'indice IVC, ou le contenu original inchangé si aucun bloc structuré n'est trouvé.
"""
segments = []
minerais = [] # Pour collecter les données de chaque minerai
intro = None

View File

@ -458,7 +458,6 @@ def build_minerai_ics_composant_section(md: str) -> str:
return md
def build_minerai_sections(md: str) -> str:
"""Traite les fiches de minerai et génère les tableaux des producteurs."""
# Extraire le type de fiche depuis l'en-tête YAML
@ -567,6 +566,8 @@ def build_minerai_sections(md: str) -> str:
md,
flags=re.DOTALL
)
# suppression pour le dernier minerai dans la fiche IHH
md = re.sub(r"# Tableaux de synthèse.*<!---- AUTO-END:SECTION-IHH-TRAITEMENT -->", "", md, flags=re.DOTALL)
except Exception as e:
st.error(f"Erreur lors de la génération des sections IHH: {e}")

View File

@ -1,18 +1,34 @@
# pastille.py
from typing import Any
PASTILLE_ICONS = {
"vert": "",
"orange": "🔶",
"rouge": "🔴"
}
def pastille(indice: str, valeur: Any, seuils: dict = None) -> str:
def pastille(indice: str, valeur: str) -> str:
"""Renvoie l'icône Unicode correspondante à la pastille en fonction de sa valeur et des seuils.
La pastille prend une couleur (vert, orange ou rouge) selon la valeur
de l'indicateur par rapport aux seuils définis. Les icônes sont définies
comme des emojis Unicode pour faciliter leur affichage dans les interfaces
interactives comme Streamlit.
Args:
indice (str): Clé permettant d'accéder au seuil spécifique.
Exemples de valeurs possibles : "criticite", "confort".
valeur (Any): Valeur numérique à comparer aux seuils.
Généralement une float, mais peut être convertie automatiquement
en nombre si possible.
Returns:
str: Une icône Unicode correspondante à la pastille. Si aucune icône n'est définie,
une chaîne vide est renvoyée.
"""
try:
import streamlit as st
seuils = seuils or st.session_state.get("seuils", {})
if indice not in seuils:
seuils = st.session_state.get("seuils", {})
if seuils and indice not in seuils:
return ""
seuil = seuils[indice]

View File

@ -12,22 +12,35 @@ Usage :
"""
from __future__ import annotations
import os
import frontmatter, yaml, jinja2, re, pathlib
from typing import Dict
from datetime import datetime, timezone
import os
from utils.gitea import recuperer_date_dernier_commit
def load_seuils(path: str | pathlib.Path = "config/indices_seuils.yaml") -> Dict:
"""Charge le fichier YAML des seuils et renvoie le dict 'seuils'."""
"""Charge le fichier YAML des seuils et renvoie le dict 'seuils'.
Args:
path (str | pathlib.Path, optional): Chemin vers le fichier des seuils. Defaults to "config/indices_seuils.yaml".
Returns:
Dict: Dictionnaire contenant les seuils.
"""
data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))
return data.get("seuils", {})
def _migrate_metadata(meta: Dict) -> Dict:
"""Normalise les clés YAML (ex : sheet_type → type_fiche)."""
"""Normalise les clés YAML (ex : sheet_type → type_fiche).
Args:
meta (Dict): Métadonnées à normaliser.
Returns:
Dict: Métadonnées normalisées.
"""
keymap = {
"sheet_type": "type_fiche",
"indice_code": "indice_court", # si besoin
@ -37,12 +50,24 @@ def _migrate_metadata(meta: Dict) -> Dict:
meta[new] = meta.pop(old)
return meta
def render_fiche_markdown(
md_text: str,
seuils: Dict,
license_path: str = "assets/licence.md"
) -> str:
"""Renvoie la fiche rendue (Markdown) avec les placeholders remplacés et le tableau de version.
def render_fiche_markdown(md_text: str, seuils: Dict, license_path: str = "assets/licence.md") -> str:
"""Renvoie la fiche rendue (Markdown) :
placeholders Jinja2 remplacés ({{ }})
table seuils injectée via dict 'seuils'.
- licence ajoutée après le tableau de version et avant le premier titre de niveau 2
Args:
md_text (str): Contenu Markdown brut.
seuils (Dict): Tableau des versions.
license_path (str, optional): Chemin vers le fichier de licence. Defaults to "assets/licence.md".
Returns:
str: Fiche Markdown rendue avec les placeholders remplacés et la table de version.
Note:
- Les licences sont ajoutées après le tableau de version.
- Les titres de niveau 2 doivent être présents pour l'insertion automatique de licence.
"""
post = frontmatter.loads(md_text)
meta = _migrate_metadata(dict(post.metadata))
@ -68,11 +93,11 @@ def render_fiche_markdown(md_text: str, seuils: Dict, license_path: str = "asset
# Charger le contenu de la licence
try:
license_content = pathlib.Path(license_path).read_text(encoding="utf-8")
# Insérer la licence après le tableau de version et avant le premier titre h2
# Trouver la position du premier titre h2
h2_match = re.search(r"^## ", rendered_body, flags=re.M)
if h2_match:
h2_position = h2_match.start()
rendered_body = f"{rendered_body[:h2_position]}\n\n{license_content}\n\n{rendered_body[h2_position:]}"
@ -81,19 +106,51 @@ def render_fiche_markdown(md_text: str, seuils: Dict, license_path: str = "asset
rendered_body = f"{rendered_body}\n\n{license_content}"
except Exception as e:
# En cas d'erreur lors de la lecture du fichier de licence, continuer sans l'ajouter
import streamlit as st
st.error(e)
pass
return rendered_body
def fichier_plus_recent(chemin_fichier, reference):
def fichier_plus_recent(
chemin_fichier: str|None,
reference: datetime
) -> bool:
"""Vérifie si un fichier est plus récent que la référence donnée.
Args:
chemin_fichier (str): Chemin vers le fichier à vérifier.
reference (datetime): Référence temporelle de comparaison.
Returns:
bool: True si le fichier est plus récent, False sinon.
"""
try:
modif = datetime.fromtimestamp(os.path.getmtime(chemin_fichier), tz=timezone.utc)
return modif > reference
except Exception:
return False
def doit_regenerer_fiche(html_path, fiche_type, fiche_choisie, commit_url, fichiers_criticite):
def doit_regenerer_fiche(
html_path: str,
fiche_type: str,
fiche_choisie: str,
commit_url: str,
fichiers_criticite: Dict[str, str]
) -> bool:
"""Détermine si une fiche doit être regénérée.
Args:
html_path (str): Chemin vers le fichier HTML.
fiche_type (str): Type de la fiche.
fiche_choisie (str): Nom choisi pour la fiche.
commit_url (str): URL du dernier commit.
fichiers_criticite (Dict[str, str]): Dictionnaire des fichiers critiques.
Returns:
bool: True si la fiche doit être regénérée, False sinon.
"""
if not os.path.exists(html_path):
return True

View File

@ -110,10 +110,6 @@ def creer_ticket_gitea(titre, corps, labels):
reponse = gitea_request("post", url, headers={"Content-Type": "application/json"}, data=json.dumps(data))
if not reponse:
return
issue_url = reponse.json().get("html_url", "")
if issue_url:
st.success(f"{str(_('pages.fiches.tickets.created_success'))} [Voir le ticket]({issue_url})")
return False
else:
st.success(str(_('pages.fiches.tickets.created')))
return True

View File

@ -74,6 +74,8 @@ def afficher_controles_formulaire():
col1, col2 = st.columns(2)
if col1.button(str(_("pages.fiches.tickets.preview"))):
st.session_state.previsualiser = True
# S'assurer que l'expander reste ouvert en mode prévisualisation
st.session_state.expander_state = True
if col2.button(str(_("pages.fiches.tickets.cancel"))):
st.session_state.previsualiser = False
st.rerun()
@ -81,6 +83,23 @@ def afficher_controles_formulaire():
def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
"""Gère la prévisualisation et la soumission du ticket."""
# Si nous avons tenté de créer un ticket (succès ou erreur)
if st.session_state.get("ticket_cree", False) or st.session_state.get("ticket_erreur", False):
if st.session_state.get("ticket_cree", False):
st.success(str(_("pages.fiches.tickets.created_success")))
else:
st.error(str(_("pages.fiches.tickets.creation_error")))
if st.button(str(_("pages.fiches.tickets.continue"))):
# Réinitialiser le formulaire et cacher l'expander
st.session_state.ticket_cree = False
st.session_state.ticket_erreur = False
st.session_state.previsualiser = False
st.session_state.expander_state = False
st.rerun()
return
# Si nous ne sommes pas en mode prévisualisation, ne rien afficher
if not st.session_state.get("previsualiser", False):
return
@ -101,15 +120,31 @@ def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
labels_ids.append(labels_existants["Backlog"])
corps = construire_corps_ticket_markdown(reponses)
creer_ticket_gitea(titre_ticket, corps, labels_ids)
resultat = creer_ticket_gitea(titre_ticket, corps, labels_ids)
# Marquer le résultat et ouvrir l'expander pour afficher le résultat
st.session_state.ticket_cree = resultat
st.session_state.ticket_erreur = not resultat
st.session_state.previsualiser = False
st.success(str(_("pages.fiches.tickets.created")))
st.session_state.expander_state = True
st.rerun()
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
"""Fonction principale pour le formulaire de création de ticket."""
with st.expander(str(_("pages.fiches.tickets.create_new")), expanded=False):
# Initialiser l'état de l'expander si ce n'est pas déjà fait
if "expander_state" not in st.session_state:
st.session_state.expander_state = False
with st.expander(str(_("pages.fiches.tickets.create_new")), expanded=st.session_state.expander_state):
# Initialiser les états si ce n'est pas déjà fait
if "ticket_cree" not in st.session_state:
st.session_state.ticket_cree = False
if "ticket_erreur" not in st.session_state:
st.session_state.ticket_erreur = False
if "previsualiser" not in st.session_state:
st.session_state.previsualiser = False
# Chargement et vérification du modèle
contenu_modele = charger_modele_ticket()
if not contenu_modele:
@ -119,11 +154,21 @@ def formulaire_creation_ticket_dynamique(fiche_selectionnee):
# Traitement du modèle et génération du formulaire
sections = parser_modele_ticket(contenu_modele)
labels, selected_ops, cible = generer_labels(fiche_selectionnee)
reponses = creer_champs_formulaire(sections, fiche_selectionnee)
# Gestion des contrôles et de la prévisualisation
afficher_controles_formulaire()
gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible)
# Créer le formulaire et gérer ses états
if st.session_state.ticket_cree or st.session_state.ticket_erreur:
# Si le ticket a été créé ou a échoué, afficher le message approprié et le bouton continuer
gerer_previsualisation_et_soumission({}, labels, selected_ops, cible)
else:
# Sinon afficher le formulaire normal
reponses = creer_champs_formulaire(sections, fiche_selectionnee)
# Afficher les contrôles uniquement si nous ne sommes pas en mode prévisualisation
if not st.session_state.previsualiser:
afficher_controles_formulaire()
# Gérer la prévisualisation et soumission
gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible)
def charger_modele_ticket():

View File

@ -6,7 +6,6 @@ import re
from collections import defaultdict
from dateutil import parser
from utils.translations import _
from .core import rechercher_tickets_gitea
def extraire_statut_par_label(ticket):

42
app/ia_nalyse/README.md Normal file
View File

@ -0,0 +1,42 @@
# Module d'Analyse
Ce module permet d'analyser les relations entre les différentes parties de la chaîne de fabrication du numérique. Il offre des outils pour visualiser les flux et identifier les vulnérabilités potentielles dans la chaîne d'approvisionnement.
## Structure du module
Le module d'analyse comprend deux composants principaux :
- **interface.py** : Gère l'interface utilisateur pour paramétrer les analyses
- **sankey.py** : Génère les diagrammes de flux (Sankey) pour visualiser les relations entre les éléments
## Fonctionnalités
### Interface d'analyse
L'interface permet de :
- Sélectionner les niveaux de départ et d'arrivée pour l'analyse (produits, composants, minerais, opérations, etc.)
- Filtrer les données par minerais spécifiques
- Effectuer une sélection fine des nœuds de départ et d'arrivée
- Appliquer des filtres pour identifier les vulnérabilités :
- Filtres ICS (criticité pour un composant)
- Filtres IVC (criticité par rapport à la concurrence sectorielle)
- Filtres IHH (concentration géographique ou industrielle)
- Filtres ISG (instabilité des pays)
- Choisir la logique de filtrage (OU, ET)
### Visualisation Sankey
Le module génère des diagrammes Sankey qui :
- Affichent les flux entre les différents éléments de la chaîne
- Mettent en évidence les relations de dépendance
- Permettent d'identifier visuellement les goulots d'étranglement potentiels
- Sont interactifs et permettent d'explorer la chaîne de valeur
## Utilisation
1. Sélectionnez un niveau de départ (ex : Produit final, Composant)
2. Choisissez un niveau d'arrivée (ex : Pays géographique, Acteur d'opération)
3. Si nécessaire, filtrez par minerais spécifiques
4. Affinez votre sélection avec des nœuds de départ et d'arrivée spécifiques
5. Appliquez les filtres de vulnérabilité souhaités
6. Lancez l'analyse pour générer le diagramme Sankey
Le diagramme résultant permet d'identifier visuellement les relations et points de vulnérabilité dans la chaîne d'approvisionnement du numérique.

View File

@ -0,0 +1,2 @@
# __init__.py app/fiches
from .interface import interface_ia_nalyse

311
app/ia_nalyse/interface.py Normal file
View File

@ -0,0 +1,311 @@
from typing import List, Optional, Tuple, Dict, Set
import streamlit as st
import networkx as nx
from utils.translations import _
from utils.widgets import html_expander
from utils.graph_utils import (
extraire_chemins_depuis,
extraire_chemins_vers
)
from batch_ia.batch_utils import soumettre_batch, statut_utilisateur, nettoyage_post_telechargement
niveau_labels = {
0: "Produit final",
1: "Composant",
2: "Minerai",
10: "Opération",
11: "Pays d'opération",
12: "Acteur d'opération",
99: "Pays géographique"
}
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
def preparer_graphe(
G: nx.DiGraph,
) -> Tuple[nx.DiGraph, Dict[str, int]]:
"""
Nettoie et prépare le graphe pour l'analyse.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
Tuple[nx.DiGraph, Dict[str, int]]: Un tuple contenant :
- Le graphe NetworkX proprement configuré
- Un dictionnaire des niveaux associés aux nœuds
"""
niveaux_temp = {
node: int(str(attrs.get("niveau")).strip('"'))
for node, attrs in G.nodes(data=True)
if attrs.get("niveau") and str(attrs.get("niveau")).strip('"').isdigit()
}
G.remove_nodes_from([n for n in G.nodes() if n not in niveaux_temp])
G.remove_nodes_from(
[n for n in G.nodes() if niveaux_temp.get(n) == 10 and 'Reserves' in n])
return G, niveaux_temp
def selectionner_minerais(
G: nx.DiGraph,
) -> Optional[List[str]]:
"""
Interface pour sélectionner les minerais si nécessaire.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
Optional[List[str]]: La liste des minerais si une sélection a été effectuée,
- None sinon
"""
minerais_selection = None
st.markdown(f"## {str(_('pages.ia_nalyse.select_minerals'))}")
# Tous les nœuds de niveau 2 (minerai)
minerais_nodes = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") and int(str(d.get("niveau")).strip('"')) == 2
])
minerais_selection = st.multiselect(
str(_("pages.ia_nalyse.filter_by_minerals")),
minerais_nodes,
key="analyse_minerais"
)
return minerais_selection
def selectionner_noeuds(
G: nx.DiGraph,
niveaux_temp: Dict[str, int],
niveau_depart: int,
) -> Tuple[Optional[List[str]], List[str]]:
"""
Interface pour sélectionner les nœuds spécifiques de départ et d'arrivée.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
niveaux_temp (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
Returns:
Tuple[Optional[List[str]], List[str]]: Un tuple contenant :
- La liste des nœuds de départ si une sélection a été effectuée,
- None sinon
- La liste des nœuds d'arrivée
"""
st.markdown("---")
st.markdown(f"## {str(_('pages.ia_nalyse.fine_selection'))}")
depart_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_depart]
noeuds_arrivee = [n for n in G.nodes() if niveaux_temp.get(n) == 99]
noeuds_depart = st.multiselect(str(_("pages.ia_nalyse.filter_start_nodes")),
sorted(depart_nodes),
key="analyse_noeuds_depart")
noeuds_depart = noeuds_depart if noeuds_depart else None
return noeuds_depart, noeuds_arrivee
def extraire_niveaux(
G: nx.DiGraph,
) -> Dict[str, int]:
"""
Extrait les niveaux des nœuds du graphe.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
Dict[str, int]: Un dictionnaire associant chaque nœud à son niveau.
"""
niveaux = {}
for node, attrs in G.nodes(data=True):
niveau_str = attrs.get("niveau")
if niveau_str:
niveaux[node] = int(str(niveau_str).strip('"'))
return niveaux
def extraire_chemins_selon_criteres(
G: nx.DiGraph,
niveaux: Dict[str, int],
niveau_depart: int,
noeuds_depart: Optional[List[str]],
noeuds_arrivee: Optional[List[str]],
minerais: Optional[List[str]],
) -> List[Tuple[str, ...]]:
"""
Extrait les chemins selon les critères spécifiés.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
noeuds_depart (Optional[List[str]]): Les nœuds de départ si sélectionnés.
noeuds_arrivee (Optional[List[str]]): Les nœuds d'arrivée si sélectionnés.
minerais (Optional[List[str]]): Les minerais si sélectionnés.
Returns:
List[Tuple[str, ...]]: Liste des chemins valides selon les critères spécifiés.
"""
chemins = []
if noeuds_depart and noeuds_arrivee:
for nd in noeuds_depart:
for na in noeuds_arrivee:
tous_chemins = extraire_chemins_depuis(G, nd)
chemins.extend([chemin for chemin in tous_chemins if na in chemin])
elif noeuds_depart:
for nd in noeuds_depart:
chemins.extend(extraire_chemins_depuis(G, nd))
elif noeuds_arrivee:
for na in noeuds_arrivee:
chemins.extend(extraire_chemins_vers(G, na, niveau_depart))
else:
sources_depart = [n for n in G.nodes() if niveaux.get(n) == niveau_depart]
for nd in sources_depart:
chemins.extend(extraire_chemins_depuis(G, nd))
if minerais:
chemins = [chemin for chemin in chemins if any(n in minerais for n in chemin)]
return chemins
def exporter_graphe_filtre(
G: nx.DiGraph,
liens_chemins: Set[Tuple[str, str]],
) -> nx.DiGraph|None:
"""
Gère l'export du graphe filtré au format DOT.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
liens_chemins (Set[Tuple[str, str]]): Ensemble des paires de nœuds liés.
Returns:
nx.DiGraph: le graphe exporté
- Sinon aucun résultat (None)
"""
from utils.persistance import get_champ_statut
if get_champ_statut("login") == "" or not liens_chemins:
return
G_export = nx.DiGraph()
for u, v in liens_chemins:
G_export.add_node(u, **G.nodes[u])
G_export.add_node(v, **G.nodes[v])
data = G.get_edge_data(u, v)
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
G_export.add_edge(u, v, **data[0])
elif isinstance(data, dict):
G_export.add_edge(u, v, **data)
else:
G_export.add_edge(u, v)
return(G_export)
def extraire_liens_filtres(
chemins: List[Tuple[str, ...]],
niveaux: Dict[str, int],
niveau_depart: int,
niveau_arrivee: int,
niveaux_speciaux: List[int]
) -> Set[Tuple[str, str]]:
"""
Extrait les liens des chemins en respectant les niveaux.
Args:
chemins (List[Tuple[str, ...]]): Liste initiale des chemins validés.
niveaux (Dict[str, int]): Dictionnaire associant chaque nœud à son niveau.
niveau_depart (int): Le niveau de départ sélectionné.
niveau_arrivee (int): Le niveau d'arrivée sélectionné.
niveaux_speciaux (List[int]): Les niveaux spéciaux à inclure dans l'extraction.
Returns:
Set[Tuple[str, str]]: Ensemble des paires de nœuds liés après filtrage.
"""
liens = set()
for chemin in chemins:
for i in range(len(chemin) - 1):
u, v = chemin[i], chemin[i + 1]
niveau_u = niveaux.get(u, 999)
niveau_v = niveaux.get(v, 999)
if (
(niveau_depart <= niveau_u <= niveau_arrivee or niveau_u in niveaux_speciaux)
and (niveau_depart <= niveau_v <= niveau_arrivee or niveau_v in niveaux_speciaux)
):
liens.add((u, v))
return liens
def interface_ia_nalyse(
G_temp: nx.DiGraph,
) -> None:
"""
Fonction principale qui s'occupe de la création du graphe pour analyse.
Args:
G_temp (nx.DiGraph): Le graphe NetworkX contenant les données des produits.
Returns:
None
"""
st.markdown(f"# {str(_('pages.ia_nalyse.title'))}")
html_expander(f"{str(_('pages.ia_nalyse.help'))}", content="\n".join(_("pages.ia_nalyse.help_content")), open_by_default=False, details_class="details_introduction")
st.markdown("---")
from utils.persistance import get_champ_statut
resultat = statut_utilisateur(get_champ_statut("login"))
if resultat:
st.info(resultat["message"])
if resultat and resultat["statut"] is None:
# Préparation du graphe
G_temp, niveaux_temp = preparer_graphe(G_temp)
# Sélection des niveaux
niveau_depart = 0
niveau_arrivee = 99
# Sélection fine des noeuds
noeuds_depart, noeuds_arrivee = selectionner_noeuds(G_temp, niveaux_temp, niveau_depart)
# Sélection des minerais si nécessaire
minerais = selectionner_minerais(G_temp)
# Étape 1 : Extraction des niveaux des nœuds
niveaux = extraire_niveaux(G_temp)
# Étape 2 : Extraction des chemins selon les critères
chemins = extraire_chemins_selon_criteres(G_temp, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais)
niveaux_speciaux = [1000, 1001, 1002, 1010, 1011, 1012]
# Extraction des liens sans filtrage
liens_chemins = extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux)
if liens_chemins:
G_final = exporter_graphe_filtre(G_temp, liens_chemins)
if st.button(str(_("pages.ia_nalyse.submit_request")), icon=":material/send:"):
soumettre_batch(get_champ_statut("login"), G_final)
st.rerun()
else:
st.info(str(_("pages.ia_nalyse.empty_graph")))
elif resultat and resultat["statut"] == "terminé" and resultat["telechargement"]:
if not st.session_state.get("telechargement_confirme"):
st.download_button(str(_("buttons.download")), resultat["telechargement"], file_name="analyse.zip", icon=":material/download:")
if st.button(str(_("pages.ia_nalyse.confirm_download")), icon=":material/task_alt:"):
nettoyage_post_telechargement(get_champ_statut("login"))
st.session_state["telechargement_confirme"] = True
st.rerun()
else:
st.success("Résultat supprimé. Vous pouvez relancer une nouvelle analyse.")
if st.button(str(_("buttons.refresh")), icon=":material/refresh:"):
st.rerun()
else:
if st.button(str(_("buttons.refresh")), icon=":material/refresh:"):
st.rerun()

View File

@ -1,6 +1,7 @@
# __init__.py app/personnalisation
from .interface import interface_personnalisation
from .ajout import ajouter_produit
from .modification import modifier_produit
from .import_export import importer_exporter_graph
__all__ = [
"interface_personnalisation"
]

View File

@ -1,23 +0,0 @@
import streamlit as st
from utils.translations import _
def ajouter_produit(G):
st.markdown(f"## {str(_('pages.personnalisation.add_new_product'))}")
new_prod = st.text_input(str(_("pages.personnalisation.new_product_name")), key="new_prod")
if new_prod:
ops_dispo = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") == "10"
and any(G.has_edge(p, n) and G.nodes[p].get("niveau") == "0" for p in G.predecessors(n))
])
sel_new_op = st.selectbox(str(_("pages.personnalisation.assembly_operation")), [str(_("pages.personnalisation.none"))] + ops_dispo, index=0)
niveau1 = sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
sel_comps = st.multiselect(str(_("pages.personnalisation.components_to_link")), options=niveau1)
if st.button(str(_("pages.personnalisation.create_product"))):
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
if sel_new_op != str(_("pages.personnalisation.none")):
G.add_edge(new_prod, sel_new_op)
for comp in sel_comps:
G.add_edge(new_prod, comp)
st.success(f"{new_prod} {str(_('pages.personnalisation.added'))}")
return G

View File

@ -1,15 +1,19 @@
# interface.py app/personnalisation
import streamlit as st
from utils.persistance import maj_champ_statut
from utils.translations import _
from .ajout import ajouter_produit
from .modification import modifier_produit
from .import_export import importer_exporter_graph
from utils.widgets import html_expander
from app.personnalisation.utils import ajouter_produit
from app.personnalisation.utils import modifier_produit
from app.personnalisation.utils import importer_exporter_graph
def interface_personnalisation(G):
st.markdown(f"# {str(_('pages.personnalisation.title'))}")
with st.expander(str(_("pages.personnalisation.help")), expanded=False):
st.markdown("\n".join(_("pages.personnalisation.help_content")))
titre = f"# {str(_('pages.personnalisation.title'))}"
maj_champ_statut("pages.personnalisation.title", titre)
st.markdown(titre)
html_expander(f"{str(_('pages.personnalisation.help'))}", content="\n".join(_("pages.personnalisation.help_content")), open_by_default=False, details_class="details_introduction")
st.markdown("---")
G = ajouter_produit(G)

View File

@ -1,87 +0,0 @@
import streamlit as st
from utils.translations import _
def get_produits_personnalises(G):
"""Récupère la liste des produits personnalisés du niveau 0."""
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "0" and d.get("personnalisation") == "oui"])
def supprimer_produit(G, prod):
"""Supprime un produit du graphe."""
G.remove_node(prod)
st.success(f"{prod} {str(_('pages.personnalisation.deleted'))}")
st.session_state.pop("prod_sel", None)
return G
def get_operations_disponibles(G):
"""Récupère la liste des opérations d'assemblage disponibles."""
return sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") == "10"
and any(G.has_edge(p, n) and G.nodes[p].get("niveau") == "0" for p in G.predecessors(n))
])
def get_operations_actuelles(G, prod):
"""Récupère les opérations actuellement liées au produit."""
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "10"]
def get_composants_niveau1(G):
"""Récupère la liste des composants de niveau 1."""
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
def get_composants_lies(G, prod):
"""Récupère les composants actuellement liés au produit."""
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "1"]
def mettre_a_jour_operations(G, prod, curr_ops, sel_op):
"""Met à jour les opérations liées au produit."""
none_option = str(_("pages.personnalisation.none", "-- Aucune --"))
for op in curr_ops:
if sel_op == none_option or op != sel_op:
G.remove_edge(prod, op)
if sel_op != none_option and (not curr_ops or sel_op not in curr_ops):
G.add_edge(prod, sel_op)
return G
def mettre_a_jour_composants(G, prod, linked, nouveaux):
"""Met à jour les composants liés au produit."""
for comp in set(linked) - set(nouveaux):
G.remove_edge(prod, comp)
for comp in set(nouveaux) - set(linked):
G.add_edge(prod, comp)
return G
def modifier_produit(G):
st.markdown(f"## {str(_('pages.personnalisation.modify_product'))}")
# Sélection du produit à modifier
produits0 = get_produits_personnalises(G)
sel_display = st.multiselect(str(_("pages.personnalisation.products_to_modify")), options=produits0)
if not sel_display:
return G
# Obtention du produit sélectionné
prod = sel_display[0]
# Suppression du produit si demandé
if st.button(f"{str(_('pages.personnalisation.delete'))} {prod}"):
return supprimer_produit(G, prod)
# Gestion des opérations d'assemblage
ops_dispo = get_operations_disponibles(G)
curr_ops = get_operations_actuelles(G, prod)
default_idx = ops_dispo.index(curr_ops[0]) + 1 if curr_ops and curr_ops[0] in ops_dispo else 0
sel_op = st.selectbox(str(_("pages.personnalisation.linked_assembly_operation")), [str(_("pages.personnalisation.none"))] + ops_dispo, index=default_idx)
# Gestion des composants
niveau1 = get_composants_niveau1(G)
linked = get_composants_lies(G, prod)
nouveaux = st.multiselect(f"{str(_('pages.personnalisation.components_linked_to'))} {prod}", options=niveau1, default=linked)
# Mise à jour des liens si demandé
if st.button(f"{str(_('pages.personnalisation.update'))} {prod}"):
G = mettre_a_jour_operations(G, prod, curr_ops, sel_op)
G = mettre_a_jour_composants(G, prod, linked, nouveaux)
st.success(f"{prod} {str(_('pages.personnalisation.updated'))}")
return G

View File

@ -0,0 +1,11 @@
# __init__.py app/personnalisation
from .ajout import ajouter_produit
from .modification import modifier_produit
from .import_export import importer_exporter_graph
__all__ = [
"ajouter_produit",
"modifier_produit",
"importer_exporter_graph"
]

View File

@ -0,0 +1,107 @@
# === Ajout de produit personnalisé ===
import streamlit as st
import networkx as nx
from utils.translations import _
from utils.persistance import get_champ_statut, maj_champ_statut, supprime_champ_statut
def ajouter_produit(G: nx.DiGraph) -> nx.DiGraph:
st.markdown(f"## {str(_('pages.personnalisation.add_new_product'))}")
# Restauration des produits personnalisés sauvegardés
if "pages.personnalisation.create_product.fait" not in st.session_state and get_champ_statut("pages.personnalisation.create_product.fait") == "oui":
index = 0
while True:
new_prod = get_champ_statut(f"pages.personnalisation.create_product.{index}.nom")
if new_prod == "":
break
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
sel_new_op = get_champ_statut(f"pages.personnalisation.create_product.{index}.edge")
if sel_new_op:
G.add_edge(new_prod, sel_new_op)
i = 0
while True:
comp = get_champ_statut(f"pages.personnalisation.create_product.{index}.composants.{i}")
if comp == "":
break
G.add_edge(new_prod, comp)
i += 1
index += 1
niveau1 = sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
ops_dispo = sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "assemblage"])
if "pages.personnalisation.add.new_product_name" not in st.session_state:
st.session_state["pages.personnalisation.add.new_product_name"] = get_champ_statut("pages.personnalisation.add.new_product_name")
new_prod = st.text_input(
str(_("pages.personnalisation.new_product_name")),
key="pages.personnalisation.add.new_product_name"
)
if new_prod:
if new_prod in G.nodes:
st.warning(str(_("pages.personnalisation.product_exists")))
return G
if "pages.personnalisation.add.assembly_operation" not in st.session_state:
st.session_state["pages.personnalisation.add.assembly_operation"] = get_champ_statut("pages.personnalisation.add.assembly_operation")
if "pages.personnalisation.add.components_to_link" not in st.session_state:
composants = []
i = 0
while True:
val = get_champ_statut(f"pages.personnalisation.add.components_to_link.{i}")
if val == "":
break
composants.append(val)
i += 1
st.session_state["pages.personnalisation.add.components_to_link"] = composants
ops_dispo = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") == "10"
and any(G.has_edge(p, n) and G.nodes[p].get("niveau") == "0" for p in G.predecessors(n))
])
sel_new_op = st.selectbox(str(_("pages.personnalisation.assembly_operation")), ops_dispo,
key="pages.personnalisation.add.assembly_operation")
sel_comps = st.multiselect(
str(_("pages.personnalisation.components_to_link")),
options=niveau1,
key="pages.personnalisation.add.components_to_link"
)
if st.button(str(_("pages.personnalisation.create_product"))):
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
# Trouver le prochain index disponible
index = 0
while get_champ_statut(f"pages.personnalisation.create_product.{index}.nom") != "":
index += 1
maj_champ_statut(f"pages.personnalisation.create_product.{index}.nom", new_prod)
if sel_new_op != str(_("pages.personnalisation.none")):
G.add_edge(new_prod, sel_new_op)
maj_champ_statut(f"pages.personnalisation.create_product.{index}.edge", sel_new_op)
for j, comp in enumerate(sel_comps):
G.add_edge(new_prod, comp)
maj_champ_statut(f"pages.personnalisation.create_product.{index}.composants.{j}", comp)
maj_champ_statut("pages.personnalisation.create_product.fait", "oui")
# Nettoyage de session et des champs persistants
del st.session_state["pages.personnalisation.add.new_product_name"]
del st.session_state["pages.personnalisation.add.assembly_operation"]
del st.session_state["pages.personnalisation.add.components_to_link"]
supprime_champ_statut("pages.personnalisation.add")
st.success(f"{new_prod} {str(_('pages.personnalisation.added'))}")
st.rerun()
return G

View File

@ -1,10 +1,11 @@
import streamlit as st
import json
from utils.translations import get_translation as _
import networkx as nx
def importer_exporter_graph(G):
def importer_exporter_graph(G: nx.DiGraph) -> nx.DiGraph:
st.markdown(f"## {_('pages.personnalisation.save_restore_config')}")
if st.button(str(_("pages.personnalisation.export_config"))):
if st.button(str(_("pages.personnalisation.export_config")), icon=":material/save:"):
nodes = [n for n, d in G.nodes(data=True) if d.get("personnalisation") == "oui"]
edges = [(u, v) for u, v in G.edges() if u in nodes]
conf = {"nodes": nodes, "edges": edges}
@ -13,7 +14,8 @@ def importer_exporter_graph(G):
label=str(_("pages.personnalisation.download_json")),
data=json_str,
file_name="config_personnalisation.json",
mime="application/json"
mime="application/json",
icon=":material/save:"
)
uploaded = st.file_uploader(str(_("pages.personnalisation.import_config")), type=["json"])
@ -37,7 +39,7 @@ def importer_exporter_graph(G):
key="restaurer_selection"
)
if st.button(str(_("pages.personnalisation.restore_selected")), type="primary"):
if st.button(str(_("pages.personnalisation.restore_selected")), type="primary", icon=":material/history:"):
for node in sel_nodes:
if not G.has_node(node):
G.add_node(node, niveau="0", personnalisation="oui", label=node)

View File

@ -0,0 +1,349 @@
from typing import List
import streamlit as st
from utils.translations import _
import networkx as nx
from utils.persistance import supprime_champ_statut, get_champ_statut, maj_champ_statut
def get_produits_personnalises(
G: nx.DiGraph
) -> List[str]:
"""
Récupère la liste des produits personnalisés du niveau 0.
Args:
G (Any): Le graphe NetworkX contenant les données des produits.
Returns:
List[str]: Liste triée des noms de produits.
"""
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "0" and d.get("personnalisation") == "oui"])
def supprimer_produit(
G: nx.DiGraph,
prod: str
) -> nx.DiGraph:
"""
Supprime un produit du graphe et affiche le message de succès.
Args:
G (Any): Le graphe NetworkX sur lequel supprimer le produit.
prod (str): Le nom du produit à supprimer.
"""
G.remove_node(prod)
st.success(f"{prod} {str(_('pages.personnalisation.deleted'))}")
st.session_state.pop("prod_sel", None)
st.rerun()
return G
def get_operations_disponibles(
G: nx.DiGraph
) -> List[str]:
"""
Récupère la liste des opérations d'assemblage disponibles.
Args:
G (Any): Le graphe NetworkX contenant les données des produits et des opérations.
Returns:
List[str]: Liste triée des noms des opérations.
"""
return sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") == "10"
and any(G.has_edge(p, n) and G.nodes[p].get("niveau") == "0" for p in G.predecessors(n))
])
def get_operations_actuelles(
G: nx.DiGraph,
prod: str
) -> List[str]:
"""
Récupère les opérations actuellement liées au produit.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits et des opérations.
prod (str): Le nom du produit dont récupérer les opérations.
Returns:
List[str]: Liste des noms des opérations actuelles.
"""
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "10"]
def get_composants_niveau1(
G: nx.DiGraph
) -> List[str]:
"""
Récupère la liste des composants de niveau 1.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits et des composants.
Returns:
List[str]: Liste triée des noms des composants.
"""
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
def get_composants_lies(
G: nx.DiGraph,
prod: str
) -> List[str]:
"""
Récupère les composants actuellement liés au produit.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits et des composants.
prod (str): Le nom du produit dont récupérer les composants.
Returns:
List[str]: Liste des noms des composants liés.
"""
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "1"]
def mettre_a_jour_operations(
G: nx.DiGraph,
prod: str,
curr_ops: List[str],
sel_op: str
) -> nx.DiGraph:
"""
Met à jour les opérations liées au produit.
Args:
G (Any): Le graphe NetworkX contenant les données des produits et des opérations.
prod (str): Le nom du produit dont mettre à jour les opérations.
curr_ops (List[str]): Liste actuelle des opérations liées.
sel_op (str): L'opération sélectionnée pour mise à jour.
Notes:
Cette fonction crée ou supprime les liens entre le produit et les opérations
selon la sélection effectuée par l'utilisateur.
"""
none_option = str(_("pages.personnalisation.none"))
for op in curr_ops:
if sel_op == none_option or op != sel_op:
G.remove_edge(prod, op)
if sel_op != none_option and (not curr_ops or sel_op not in curr_ops):
G.add_edge(prod, sel_op)
return G
def mettre_a_jour_composants(
G: nx.DiGraph,
prod: str,
linked: List[str],
nouveaux: List[str]
) -> nx.DiGraph:
"""
Met à jour les composants liés au produit.
Args:
G (nx.DiGraph): Le graphe NetworkX contenant les données des produits et des composants.
prod (str): Le nom du produit dont mettre à jour les composants.
linked (List[str]): Liste actuelle des composants liés.
nouveaux (List[str]): Nouvelle liste de composants à lier.
Notes:
Cette fonction crée ou supprime les liens entre le produit et les composants
selon la sélection effectuée par l'utilisateur.
"""
"""Met à jour les composants liés au produit."""
for comp in set(linked) - set(nouveaux):
G.remove_edge(prod, comp)
for comp in set(nouveaux) - set(linked):
G.add_edge(prod, comp)
return G
def modifier_produit(
G: nx.DiGraph
) -> nx.DiGraph:
"""
Méthode de personnalisation qui permet à l'utilisateur d'ajuster un produit.
Args:
G (Any): Le graphe NetworkX sur lequel modifier les produits et leurs composants.
Contient des données concernant la personalisation des produits,
leur niveau, et les opérations liées.
Notes:
Cette fonction fournit une interface utilisateur pour sélectionner
un produit à personnaliser, gérer ses composants, et définir ses opérations
d'assemblage. Elle implémente la logique de mise à jour des connexions entre
les différents éléments du graphe.
"""
st.markdown(f"## {str(_('pages.personnalisation.modify_product'))}")
# Restauration des produits personnalisés sauvegardés
if "pages.personnalisation.create_product.fait" not in st.session_state and get_champ_statut("pages.personnalisation.create_product.fait") == "oui":
index = 0
while True:
new_prod = get_champ_statut(f"pages.personnalisation.create_product.{index}.nom")
if new_prod == "":
break
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
sel_new_op = get_champ_statut(f"pages.personnalisation.create_product.{index}.edge")
if sel_new_op:
G.add_edge(new_prod, sel_new_op)
i = 0
while True:
comp = get_champ_statut(f"pages.personnalisation.create_product.{index}.composants.{i}")
if comp == "":
break
G.add_edge(new_prod, comp)
i += 1
index += 1
st.session_state["pages.personnalisation.create_product.fait"] = "oui"
# Sélection du produit à modifier
produits0 = get_produits_personnalises(G)
# Restaurer la sélection du produit depuis la persistance
if "pages.personnalisation.modify.selected_product" not in st.session_state:
st.session_state["pages.personnalisation.modify.selected_product"] = get_champ_statut("pages.personnalisation.modify.selected_product")
sel_display = st.selectbox(
label=str(_("pages.personnalisation.products_to_modify")),
options=produits0,
key="pages.personnalisation.modify.selected_product"
)
if sel_display:
maj_champ_statut("pages.personnalisation.modify.selected_product", sel_display)
if not sel_display:
return G
# Obtention du produit sélectionné
prod = sel_display
# Trouver l'index du produit dans la sauvegarde
index_key = None
index = 0
while True:
saved_name = get_champ_statut(f"pages.personnalisation.create_product.{index}.nom")
if saved_name == "":
break
if saved_name == prod:
index_key = index
break
index += 1
# Suppression du produit si demandé
if st.button(f"{str(_('pages.personnalisation.delete'))} {prod}"):
if index_key is not None:
# Supprimer les données du produit
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.nom")
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.edge")
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.composants")
# Réorganiser les indices pour combler le trou
next_index = index_key + 1
while get_champ_statut(f"pages.personnalisation.create_product.{next_index}.nom") != "":
# Déplacer le produit suivant vers l'index actuel
nom = get_champ_statut(f"pages.personnalisation.create_product.{next_index}.nom")
edge = get_champ_statut(f"pages.personnalisation.create_product.{next_index}.edge")
maj_champ_statut(f"pages.personnalisation.create_product.{next_index - 1}.nom", nom)
if edge:
maj_champ_statut(f"pages.personnalisation.create_product.{next_index - 1}.edge", edge)
else:
supprime_champ_statut(f"pages.personnalisation.create_product.{next_index - 1}.edge")
# Copier les composants
i = 0
while True:
comp = get_champ_statut(f"pages.personnalisation.create_product.{next_index}.composants.{i}")
if comp == "":
break
maj_champ_statut(f"pages.personnalisation.create_product.{next_index - 1}.composants.{i}", comp)
i += 1
# Supprimer l'ancien emplacement
supprime_champ_statut(f"pages.personnalisation.create_product.{next_index}")
next_index += 1
# Nettoyer aussi les champs de modification
supprime_champ_statut("pages.personnalisation.modify")
if "pages.personnalisation.modify.selected_product" in st.session_state:
del st.session_state["pages.personnalisation.modify.selected_product"]
if "pages.personnalisation.modify.selected_operation" in st.session_state:
del st.session_state["pages.personnalisation.modify.selected_operation"]
if "pages.personnalisation.modify.selected_components" in st.session_state:
del st.session_state["pages.personnalisation.modify.selected_components"]
return supprimer_produit(G, prod)
# Gestion des opérations d'assemblage
ops_dispo = get_operations_disponibles(G)
curr_ops = get_operations_actuelles(G, prod)
# Restaurer la sélection de l'opération depuis la persistance
if "pages.personnalisation.modify.selected_operation" not in st.session_state:
saved_op = get_champ_statut("pages.personnalisation.modify.selected_operation")
if saved_op and saved_op in [str(_("pages.personnalisation.none"))] + ops_dispo:
st.session_state["pages.personnalisation.modify.selected_operation"] = saved_op
else:
default_op = curr_ops[0] if curr_ops else str(_("pages.personnalisation.none"))
st.session_state["pages.personnalisation.modify.selected_operation"] = default_op
sel_op = st.selectbox(
str(_("pages.personnalisation.linked_assembly_operation")),
[str(_("pages.personnalisation.none"))] + ops_dispo,
key="pages.personnalisation.modify.selected_operation"
)
if sel_op:
maj_champ_statut("pages.personnalisation.modify.selected_operation", sel_op)
# Gestion des composants
niveau1 = get_composants_niveau1(G)
linked = get_composants_lies(G, prod)
# Restaurer la sélection des composants depuis la persistance
if "pages.personnalisation.modify.selected_components" not in st.session_state:
saved_comps = []
i = 0
while True:
comp = get_champ_statut(f"pages.personnalisation.modify.selected_components.{i}")
if comp == "":
break
if comp in niveau1:
saved_comps.append(comp)
i += 1
st.session_state["pages.personnalisation.modify.selected_components"] = saved_comps if saved_comps else linked
nouveaux = st.multiselect(
f"{str(_('pages.personnalisation.components_linked_to'))} {prod}",
options=niveau1,
key="pages.personnalisation.modify.selected_components"
)
if nouveaux:
supprime_champ_statut("pages.personnalisation.modify.selected_components")
for i, comp in enumerate(nouveaux):
maj_champ_statut(f"pages.personnalisation.modify.selected_components.{i}", comp)
# Mise à jour des liens si demandé
if st.button(f"{str(_('pages.personnalisation.update'))} {prod}"):
G = mettre_a_jour_operations(G, prod, curr_ops, sel_op)
if index_key is not None:
if sel_op != str(_("pages.personnalisation.none")):
maj_champ_statut(f"pages.personnalisation.create_product.{index_key}.edge", sel_op)
else:
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.edge")
G = mettre_a_jour_composants(G, prod, linked, nouveaux)
if index_key is not None:
if nouveaux:
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.composants")
for j, comp in enumerate(nouveaux):
maj_champ_statut(f"pages.personnalisation.create_product.{index_key}.composants.{j}", comp)
else:
supprime_champ_statut(f"pages.personnalisation.create_product.{index_key}.composants")
st.success(f"{prod} {str(_('pages.personnalisation.updated'))}")
# Forcer la sauvegarde de l'état
maj_champ_statut("pages.personnalisation.create_product.fait", "oui")
return G

View File

@ -0,0 +1,34 @@
# app/plan_d_action/__init__.py
from .utils.data.plan_d_action import initialiser_interface
from .utils.interface.parser import preparer_graphe
from .utils.interface.niveau_utils import extraire_niveaux
from .utils.interface.selection import (
selectionner_minerais,
selectionner_noeuds,
extraire_chemins_selon_criteres
)
from .utils.interface.export import (
exporter_graphe_filtre,
extraire_liens_filtres
)
from .utils.interface.visualization import remplacer_par_badge
from .utils.interface.config import (
niveau_labels,
JOBS
)
__all__ = [
"initialiser_interface",
"preparer_graphe",
"extraire_niveaux",
"selectionner_minerais",
"selectionner_noeuds",
"extraire_chemins_selon_criteres",
"exporter_graphe_filtre",
"extraire_liens_filtres",
"remplacer_par_badge",
"niveau_labels",
"JOBS"
]

View File

@ -0,0 +1,127 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
import networkx as nx
"""
Script pour générer un rapport factorisé des vulnérabilités critiques
suivant la structure définie dans Remarques.md.
"""
import streamlit as st
import uuid
from utils.translations import _
from utils.widgets import html_expander
from networkx.drawing.nx_agraph import write_dot
from batch_ia import (
load_config,
write_report,
parse_graphs,
extract_data_from_graph,
calculate_vulnerabilities,
generate_report,
)
from app.plan_d_action import (
initialiser_interface,
preparer_graphe,
extraire_niveaux,
selectionner_minerais,
selectionner_noeuds,
extraire_chemins_selon_criteres,
exporter_graphe_filtre,
extraire_liens_filtres,
niveau_labels,
remplacer_par_badge,
JOBS
)
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
def interface_plan_d_action(G_temp: nx.DiGraph) -> None:
"""Interface pour planifier l'action de sélection des minerais.
Args:
G_temp (nx.DiGraph): Le graphe temporaire à analyser.
Returns:
None: Modifie le state du Streamlit avec les données nécessaires
pour la génération du rapport factorisé des vulnérabilités critiques.
"""
if "sel_prod" not in st.session_state:
st.session_state.sel_prod = None
if "sel_comp" not in st.session_state:
st.session_state.sel_comp = None
if "sel_miner" not in st.session_state:
st.session_state.sel_miner = None
if "plan_d_action" not in st.session_state:
st.session_state["plan_d_action"] = 0
if "g_md_done" not in st.session_state:
st.session_state["g_md_done"] = False
if "uuid" not in st.session_state:
st.session_state["uuid"] = str(uuid.uuid4())[:8]
st.session_state["G_dot"] = f"{JOBS}/{st.session_state["uuid"]}.dot"
st.session_state["G_md"] = f"{JOBS}/{st.session_state["uuid"]}.md"
if st.session_state["plan_d_action"] == 0:
st.markdown(f"# {str(_('pages.plan_d_action.title'))}")
html_expander(f"{str(_('pages.plan_d_action.help'))}", content="\n".join(_("pages.plan_d_action.help_content")), open_by_default=False, details_class="details_introduction")
# Préparation du graphe
G_temp, niveaux_temp = preparer_graphe(G_temp)
# Sélection des niveaux
niveau_depart = 0
niveau_arrivee = 99
# Sélection fine des noeuds
noeuds_depart, noeuds_arrivee = selectionner_noeuds(G_temp, niveaux_temp, niveau_depart)
# Sélection des minerais si nécessaire
if noeuds_depart:
minerais = selectionner_minerais(G_temp, noeuds_depart)
# Étape 1 : Extraction des niveaux des nœuds
niveaux = extraire_niveaux(G_temp)
# Étape 2 : Extraction des chemins selon les critères
chemins = extraire_chemins_selon_criteres(G_temp, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais)
niveaux_speciaux = [1000, 1001, 1002, 1010, 1011, 1012]
# Extraction des liens sans filtrage
liens_chemins = extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux)
else:
liens_chemins = None
if liens_chemins:
G_final = exporter_graphe_filtre(G_temp, liens_chemins)
st.session_state["G_final"] = G_final
# formulaire ou sélection
if st.button(str(_("pages.plan_d_action.submit_request")), icon=":material/send:"):
# On déclenche la suite — mais on NE traite rien maintenant
st.session_state["plan_d_action"] = 1
st.rerun() # force la réexécution immédiatement avec état mis à jour
elif st.session_state["plan_d_action"] == 1:
st.markdown("")
# Traitement lourd une seule fois
if not st.session_state["g_md_done"]:
write_dot(st.session_state["G_final"], st.session_state["G_dot"])
config = load_config()
graph, ref_graph = parse_graphs(st.session_state["G_dot"])
data = extract_data_from_graph(graph, ref_graph)
results = calculate_vulnerabilities(data, config)
report, file_names = generate_report(data, results, config)
write_report(remplacer_par_badge(report), st.session_state["G_md"])
st.session_state["g_md_done"] = True # pour ne pas re-traiter à chaque affichage
# Affichage de linterface Streamlit
initialiser_interface(st.session_state["G_md"])
if (st.button("Réinitialiser", icon=":material/refresh:")):
st.session_state["plan_d_action"] = 0
st.session_state["g_md_done"] = False
st.session_state.sel_prod = None
st.session_state.sel_comp = None
st.session_state.sel_miner = None
for f in JOBS.glob(f"*{st.session_state["uuid"]}*"):
if f.is_file():
f.unlink()
st.rerun()

View File

@ -0,0 +1,29 @@
from .config import (
PRECONISATIONS,
INDICATEURS,
poids_operation
)
from .data_processing import parse_chains_md
from .data_utils import (
set_vulnerability,
colorer_couleurs,
initialiser_seuils
)
from .pda_interface import (
afficher_bloc_ihh_isg,
afficher_description,
afficher_caracteristiques_minerai
)
__all__ = [
"PRECONISATIONS",
"INDICATEURS",
"poids_operation",
"parse_chains_md",
"set_vulnerability",
"colorer_couleurs",
"initialiser_seuils",
"afficher_bloc_ihh_isg",
"afficher_description",
"afficher_caracteristiques_minerai"
]

View File

@ -0,0 +1,165 @@
PRECONISATIONS = {
'Facile': [
"Constituer des stocks stratégiques.",
"Surveiller activement les signaux géopolitiques.",
"Renforcer la surveillance des régions critiques."
],
'Modérée': [
"Diversifier progressivement les fournisseurs.",
"Favoriser la modularité des produits.",
"Augmenter progressivement les taux de recyclage."
],
'Difficile': [
"Investir fortement en R&D pour la substitution.",
"Développer des technologies alternatives robustes.",
"Établir des partenariats stratégiques locaux solides."
],
'Extraction': {
'Facile': [
"Constituer des stocks « in-country » (site minier / port) pour 30 jours.",
"Activer un moniteur de prix spot quotidien.",
"Lancer une veille ESG locale (manifestations, météo extrême)."
],
'Modérée': [
"Négocier des contrats « take-or-pay » avec au moins 2 exploitants distincts.",
"Mettre en place un audit semestriel des pratiques de sécurité/logistique des mines.",
"Financer en co-investissement un entrepôt portuaire multi-produits."
],
'Difficile': [
"Participer au capital dun producteur émergent hors zone de concentration.",
"Obtenir des droits d« off-take » de 5 ans sur 20 % de la production dune mine alternative.",
"Soutenir (CAPEX) louverture dune nouvelle voie ferroviaire ou portuaire sécurisée."
]
},
'Traitement': {
'Facile': [
"Sécuriser un stock tampon sur site (90 jours).",
"Faire certifier la traçabilité chimique du concentré."
],
'Modérée': [
"Valider un second affineur dans une région politiquement stable.",
"Imposer des clauses « force-majeure » limitant larrêt total à 48 h.",
"Explorer les possibilités de recyclage et d'économie circulaire"
],
'Difficile': [
"Co-développer un site de raffinage dans une zone « friend-shore ».",
"Financer un procédé de purification à rendement plus élevé (réduit la dépendance au minerai primaire).",
"Constituer des réserves stratégiques pour les périodes de tension"
]
},
'Fabrication': {
'Facile': [
"Mettre un seuil minimal de sécurité (45 jours) sur le composant critique en usine SMT.",
"Suivre hebdomadairement la capacité libre des fondeurs/EMS.",
"Maintenir une veille technologique sur les évolutions du marché"
],
'Modérée': [
"Dual-sourcer le composant critique intégrant un minerai critique (au moins 30 % chez un second fondeur).",
"Déployer le « design-for-substitution » : même PCB compatible avec le composant concerné.",
"Optimiser les processus d'approvisionnement existants"
],
'Difficile': [
"Lancer un programme R&D de substitution ou d'alternative budgeté sur 3 ans.",
"Contractualiser un accord exclusif avec un fondeur hors zone rouge pour 25 % des volumes."
]
},
'Assemblage': {
'Facile': [
"Allonger la rotation des stocks de produits finis (en aval) pour amortir un retard de 2 semaines.",
"Mettre en place un plan de re-déploiement du personnel sur dautres lignes en cas de rupture composant."
],
'Modérée': [
"Avoir un site dassemblage secondaire (low-volume) dans une région verte, testé tous les 6 mois.",
"Segmenter les nomenclatures : version « premium » avec composant haut de gamme, version « fallback » avec composant moins critique."
],
'Difficile': [
"Investir dans une plateforme dassemblage flexible (robots modulaires) capable de basculer vers un composant de substitution en < 72 h.",
"Signer un accord gouvernemental pour un soutien logistique prioritaire (corridor aérien dédié) en cas de crise géopolitique.",
"Mettre en place des contrats à long terme avec des clauses de garantie d'approvisionnement"
]
}
}
INDICATEURS = {
'Facile': [
"Suivi régulier de la stabilité géopolitique (ISG).",
"Durée réelle d'utilisation du matériel.",
"Niveau des stocks stratégiques disponibles."
],
'Modérée': [
"Taux de diversification des fournisseurs par région.",
"Évolution trimestrielle de la concurrence intersectorielle (IVC).",
"Taux annuel de recyclage des composants critiques."
],
'Difficile': [
"Budget annuel investi dans la recherche technologique.",
"Nombre de brevets déposés pour des substituts.",
"Progrès réel en matière de substitution technologique (ICS)."
],
'Extraction': {
'Facile': [
"Jours de stock portuaire (objectif ≥ 30).",
"Indice ISG moyen pondéré des pays extracteurs (alerte ≥ 60).",
"Volatilité hebdo du prix spot (écart-type %)."
],
'Modérée': [
"Part du 2ᵉ fournisseur dans le volume total (objectif ≥ 20 %).",
"Délai moyen dobtention des permis dexport."
],
'Difficile': [
"Capacité annuelle dune mine alternative financée (% du besoin interne).",
"Progrès physique de linfrastructure logistique (Km de voie, % achevé)."
]
},
'Traitement': {
'Facile': [
"Couverture stock tampon (jours).",
"Certificats de traçabilité obtenus (% lots)."
],
'Modérée': [
"Nombre daffineurs validés (objectif ≥ 2).",
"Taux de rendement global du procédé (%)."
],
'Difficile': [
"Part de production refinée hors zone rouge (%).",
"Capex cumulé investi dans de nouveaux procédés (M€)."
]
},
'Fabrication': {
'Facile': [
"Stock de composants critiques (jours).",
"Capacité libre des EMS (%) rapportée chaque vendredi."
],
'Modérée': [
"Part du second fondeur dans la production du composant (%).",
"Nombre de PCB « design-for-substitution » validés."
],
'Difficile': [
"Dépenses R&D substituts (€) vs budget.",
"TRI attendu sur les investisseurs fondeurs alternatifs."
]
},
'Assemblage': {
'Facile': [
"Jours de produits finis en entrepôt.",
"Temps de retouche ligne en cas de rupture (heures)."
],
'Modérée': [
"Volume annuel produit sur le site de secours (%).",
"Temps de requalification dune ligne vers la version « fallback »."
],
'Difficile': [
"Taux dautomatisation reconfigurable (% machines modulaires).",
"Nb dheures du corridor aérien prioritaire utilisé vs capacité."
]
}
}
poids_operation = {
'Extraction': 1,
'Traitement': 1.5,
'Assemblage': 1.5,
'Fabrication': 2,
'Substitution': 2
}

View File

@ -0,0 +1,201 @@
import re
def parse_chains_md(filepath: str) -> tuple[dict, dict, dict, list, dict, dict]:
"""Lit et analyse un fichier Markdown contenant des informations sur les chaînes minérales.
Args:
filepath (str): Chemin vers le fichier Markdown à analyser.
Returns:
tuple: Un ensemble de dictionnaires et listes contenant les données extraites du fichier,
incluant les produits, composants, mineraux, chaînes et leurs descriptions détaillées.
"""
re_start_section = re.compile(r"^##\s*Chaînes\s+avec\s+risque\s+critique", re.IGNORECASE)
re_other_h2 = re.compile(r"^##\s+(?!(Chaînes\s+avec\s+risque\s+critique))")
re_chain_heading = re.compile(r"^###\s*(.+)\s*→\s*(.+)\s*→\s*(.+)$")
re_phase = re.compile(r"^\*\s*(Assemblage|Fabrication|Minerai|Extraction|Traitement)", re.IGNORECASE)
re_IHH = re.compile(r"IHH\s*[:]\s*([0-9]+(?:\.[0-9]+)?)")
re_ISG = re.compile(r"ISG\s*combiné\s*[:]\s*([0-9]+(?:\.[0-9]+)?)|ISG\s*[:]\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE)
re_ICS = re.compile(r"ICS\s*moyen\s*[:]\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE)
re_IVC = re.compile(r"IVC\s*[:]\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE)
produits, composants, mineraux, chains = {}, {}, {}, []
descriptions = {}
details_sections = {}
current_chain = None
current_phase = None
current_section = None
in_section = False
with open(filepath, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not in_section:
if re_start_section.match(line):
in_section = True
continue
if re_other_h2.match(line):
break
m_chain = re_chain_heading.match(line)
if m_chain:
prod, comp, miner = map(str.strip, m_chain.groups())
produits.setdefault(prod, {"IHH_Assemblage": None, "ISG_Assemblage": None})
composants.setdefault(comp, {"IHH_Fabrication": None, "ISG_Fabrication": None})
mineraux.setdefault(miner, {
"ICS": None, "IVC": None,
"IHH_Extraction": None, "ISG_Extraction": None,
"IHH_Traitement": None, "ISG_Traitement": None
})
chains.append({"produit": prod, "composant": comp, "minerai": miner})
current_chain = {"prod": prod, "comp": comp, "miner": miner}
current_phase = None
current_section = f"{prod}{comp}{miner}"
descriptions[current_section] = ""
continue
if current_chain is None:
continue
m_phase = re_phase.match(line)
if m_phase:
current_phase = m_phase.group(1).capitalize()
continue
if current_phase:
p = current_chain
if current_phase == "Assemblage":
if (m := re_IHH.search(line)):
produits[p["prod"]]["IHH_Assemblage"] = float(m.group(1))
continue
if (m := re_ISG.search(line)):
raw = m.group(1) or m.group(2)
produits[p["prod"]]["ISG_Assemblage"] = float(raw)
continue
if current_phase == "Fabrication":
if (m := re_IHH.search(line)):
composants[p["comp"]]["IHH_Fabrication"] = float(m.group(1))
continue
if (m := re_ISG.search(line)):
raw = m.group(1) or m.group(2)
composants[p["comp"]]["ISG_Fabrication"] = float(raw)
continue
if current_phase == "Minerai":
if (m := re_ICS.search(line)):
mineraux[p["miner"]]["ICS"] = float(m.group(1))
continue
if (m := re_IVC.search(line)):
mineraux[p["miner"]]["IVC"] = float(m.group(1))
continue
if current_phase == "Extraction":
if (m := re_IHH.search(line)):
mineraux[p["miner"]]["IHH_Extraction"] = float(m.group(1))
continue
if (m := re_ISG.search(line)):
raw = m.group(1) or m.group(2)
mineraux[p["miner"]]["ISG_Extraction"] = float(raw)
continue
if current_phase == "Traitement":
if (m := re_IHH.search(line)):
mineraux[p["miner"]]["IHH_Traitement"] = float(m.group(1))
continue
if (m := re_ISG.search(line)):
raw = m.group(1) or m.group(2)
mineraux[p["miner"]]["ISG_Traitement"] = float(raw)
continue
else:
if current_section:
descriptions[current_section] += raw_line
# Parse detailed sections from the complete file
with open(filepath, encoding="utf-8") as f:
content = f.read()
# Extract sections using regex patterns
lines = content.split('\n')
# Find section boundaries
operations_start = None
minerais_start = None
for i, line in enumerate(lines):
if line.strip() == "## Détails des opérations":
operations_start = i
elif line.strip() == "## Détails des minerais":
minerais_start = i
if operations_start is not None:
# Parse operations section (assemblage and fabrication)
operations_end = minerais_start if minerais_start else len(lines)
operations_lines = lines[operations_start:operations_end]
current_section_name = None
current_content = []
for line in operations_lines:
if line.startswith("### ") and " et " in line:
# Save previous section
if current_section_name and current_content:
details_sections[current_section_name] = '\n'.join(current_content)
# Start new section
section_title = line.replace("### ", "").strip()
if " et Assemblage" in section_title:
product_name = section_title.replace(" et Assemblage", "").strip()
current_section_name = f"{product_name}_assemblage"
elif " et Fabrication" in section_title:
component_name = section_title.replace(" et Fabrication", "").strip()
current_section_name = f"{component_name}_fabrication"
current_content = []
elif current_section_name:
current_content.append(line)
# Save last section
if current_section_name and current_content:
details_sections[current_section_name] = '\n'.join(current_content)
if minerais_start is not None:
# Parse minerais section
minerais_lines = lines[minerais_start:]
current_minerai = None
current_section_type = "general"
current_content = []
for line in minerais_lines:
if line.startswith("### ") and "" not in line and " et " not in line:
# Save previous section
if current_minerai and current_content:
details_sections[f"{current_minerai}_{current_section_type}"] = '\n'.join(current_content)
# Start new minerai
current_minerai = line.replace("### ", "").strip()
current_section_type = "general"
current_content = []
elif line.startswith("#### Extraction"):
# Save previous section
if current_minerai and current_content:
details_sections[f"{current_minerai}_{current_section_type}"] = '\n'.join(current_content)
current_section_type = "extraction"
current_content = []
elif line.startswith("#### Traitement"):
# Save previous section
if current_minerai and current_content:
details_sections[f"{current_minerai}_{current_section_type}"] = '\n'.join(current_content)
current_section_type = "traitement"
current_content = []
elif line.startswith("## ") and current_minerai:
# End of minerais section
if current_content:
details_sections[f"{current_minerai}_{current_section_type}"] = '\n'.join(current_content)
break
elif current_minerai:
current_content.append(line)
# Save last section
if current_minerai and current_content:
details_sections[f"{current_minerai}_{current_section_type}"] = '\n'.join(current_content)
return produits, composants, mineraux, chains, descriptions, details_sections

View File

@ -0,0 +1,104 @@
import yaml
import streamlit as st
def get_seuil(seuils_dict: dict, key: str) -> float|None:
"""Récupère un seuil pour une clé donnée dans le dictionnaire.
Args:
seuils_dict (dict): Dictionnaire contenant les seuils.
key (str): Clé du seuil à récupérer.
Returns:
float|None: Le seuil si existant, sinon None.
"""
try:
if key in seuils_dict:
data = seuils_dict[key]
for niveau in ["rouge", "orange", "vert"]:
if niveau in data:
seuil = data[niveau]
if "min" in seuil and seuil["min"] is not None:
return seuil["min"]
if "max" in seuil and seuil["max"] is not None:
return seuil["max"]
except:
pass
return None
def set_vulnerability(v1: int, v2: int, t1: str, t2: str, seuils: dict) -> tuple[int,str,str,str]:
"""Calcule la vulnérabilité en fonction des seuils.
Args:
v1 (int): Valeur de vulnérabilité 1.
v2 (int): Valeur de vulnérabilité 2.
t1 (str): Type 1.
t2 (str): Type 2.
seuils (dict): Dictionnaire des seuils.
Returns:
tuple[int, str, str, str]: Poids et couleurs pour les deux types.
"""
v1_poids = 1
v1_couleur = "Vert"
if v1 > seuils[t1]["rouge"]["min"]:
v1_poids = 3
v1_couleur = "Rouge"
elif v1 > seuils[t1]["vert"]["max"]:
v1_poids = 2
v1_couleur = "Orange"
v2_poids = 1
v2_couleur = "Vert"
if v2 > seuils[t2]["rouge"]["min"]:
v2_poids = 3
v2_couleur = "Rouge"
elif v2 > seuils[t2]["vert"]["max"]:
v2_poids = 2
v2_couleur = "Orange"
poids = v1_poids * v2_poids
couleur = "Rouge"
if poids <= 2:
couleur = "Vert"
elif poids <= 4:
couleur = "Orange"
return poids, couleur, v1_couleur, v2_couleur
def colorer_couleurs(la_couleur: str) -> str:
"""Convertit une couleur en badge Markdown.
Args:
la_couleur (str): Couleur à convertir (rouge/difficile, orange/modérée, vert/facile).
Returns:
str: Badge Markdown correspondant.
"""
t = la_couleur.lower()
if t == "rouge" or t == "difficile":
return f":red-badge[{la_couleur}]"
if t == "orange" or t == "modérée":
return f":orange-badge[{la_couleur}]"
if t == "vert" or t == "facile":
return f":green-badge[{la_couleur}]"
return la_couleur
def initialiser_seuils(config_path: str) -> dict:
"""Charge les seuils depuis un fichier YAML de configuration.
Args:
config_path (str): Chemin vers le fichier de configuration YAML.
Returns:
dict: Dictionnaire des seuils chargés.
"""
seuils = {}
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
seuils = config.get("seuils", seuils)
except FileNotFoundError:
st.warning(f"Fichier de configuration {config_path} non trouvé.")
return seuils

View File

@ -0,0 +1,243 @@
import streamlit as st
def afficher_bloc_ihh_isg(titre, ihh, isg, details_content="", ui = True) -> str|None:
contenu_bloc = ""
if ui:
st.markdown(f"### {titre}")
else:
contenu_bloc = f"### {titre}\n"
if not details_content:
st.markdown("Données non disponibles")
return
lines = details_content.split('\n')
# 1. Afficher vulnérabilité combinée en premier
if "#### Vulnérabilité combinée IHH-ISG" in details_content:
contenu_md = "#### Vulnérabilité combinée IHH-ISG\n"
contenu_md += afficher_section_texte(lines, "#### Vulnérabilité combinée IHH-ISG", "###")
contenu_md += "\n"
if ui:
conteneur, = st.columns([1], gap="small", border=True)
with conteneur:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# 2. Afficher ISG des pays impliqués
if "##### ISG des pays impliqués" in details_content:
# Afficher le résumé ISG combiné
for line in lines:
if "**ISG combiné:" in line:
st.markdown(line)
break
contenu_md = "#### ISG des pays impliqués\n"
contenu_md += afficher_section_avec_tableau(lines, "##### ISG des pays impliqués")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# 3. Afficher la section IHH complète
if "#### Indice de Herfindahl-Hirschmann" in details_content:
contenu_md = "#### Indice de Herfindahl-Hirschmann\n"
# Tableau de résumé IHH
contenu_md += afficher_section_avec_tableau(lines, "#### Indice de Herfindahl-Hirschmann")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# IHH par entreprise
if "##### IHH par entreprise (acteurs)" in details_content:
contenu_md = "##### IHH par entreprise (acteurs)\n"
contenu_md += afficher_section_texte(lines, "##### IHH par entreprise (acteurs)", "##### IHH par pays")
st.markdown(contenu_md)
# IHH par pays
if "##### IHH par pays" in details_content:
contenu_md = "##### IHH par pays\n"
contenu_md += afficher_section_texte(lines, "##### IHH par pays", "##### En résumé")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# En résumé
if "##### En résumé" in details_content:
contenu_md = "##### En résumé\n"
contenu_md += afficher_section_texte(lines, "##### En résumé", "####")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
if not ui:
return contenu_bloc
else:
return None
def afficher_section_avec_tableau(lines, section_start, section_end=None):
"""Affiche une section contenant un tableau"""
in_section = False
table_lines = []
for line in lines:
if section_start in line:
in_section = True
continue
elif in_section and section_end and section_end in line:
break
elif in_section and line.startswith('#') and section_start not in line:
break
elif in_section:
if line.strip().startswith('|'):
table_lines.append(line)
elif table_lines and not line.strip().startswith('|'):
# Fin du tableau
break
if table_lines:
contenu = '\n'.join(table_lines)
return contenu
def afficher_section_texte(lines, section_start, section_end_marker=None):
"""Affiche le texte d'une section sans les tableaux"""
in_section = False
contenu_md = []
for line in lines:
if section_start in line:
in_section = True
continue
elif in_section and section_end_marker and line.startswith(section_end_marker):
break
elif in_section and line.startswith('#') and section_start not in line:
break
elif in_section and line.strip() and not line.strip().startswith('|'):
contenu_md.append(line + '\n')
contenu = '\n'.join(contenu_md)
return contenu
def afficher_description(titre, description, ui = True) -> str|None:
contenu_bloc = ""
if ui:
st.markdown(f"### {titre}")
else:
contenu_bloc = f"### {titre}\n"
if description:
lines = description.split('\n')
description_lines = []
# Extraire le premier paragraphe descriptif
for line in lines:
line = line.strip()
if not line:
if description_lines: # Si on a déjà du contenu, une ligne vide termine le paragraphe
break
continue
# Arrêter aux titres de sections ou tableaux
if (line.startswith('####') or
line.startswith('|') or
line.startswith('**Unité')):
break
description_lines.append(line)
if description_lines:
# Rejoindre les lignes en un seul paragraphe
contenu_md = ' '.join(description_lines)
else:
contenu_md = "Description non disponible"
else:
contenu_md = "Description non disponible"
if ui:
conteneur, = st.columns([1], gap="small", border=True)
with conteneur:
st.markdown(contenu_md)
return None
else:
contenu_bloc += contenu_md
return contenu_bloc
def afficher_caracteristiques_minerai(minerai, mineraux_data, details_content="", ui = True) -> str|None:
contenu_bloc = ""
if ui:
st.markdown("### Caractéristiques générales")
else:
contenu_bloc = "### Caractéristiques générales\n"
if not details_content:
if ui:
st.markdown("Données non disponibles")
else:
contenu_bloc += "Données non disponibles\n"
return
lines = details_content.split('\n')
# 3. Afficher la vulnérabilité combinée ICS-IVC en dernier
if "#### Vulnérabilité combinée ICS-IVC" in details_content:
contenu_md = "#### Vulnérabilité combinée ICS-IVC\n"
contenu_md += afficher_section_texte(lines, "#### Vulnérabilité combinée ICS-IVC", "####")
contenu_md += "\n"
if ui:
conteneur, = st.columns([1], gap="small", border=True)
with conteneur:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# 1. Afficher la section ICS complète
if "#### ICS" in details_content:
contenu_md = "#### ICS\n"
# Afficher le premier tableau ICS (avec toutes les colonnes)
contenu_md += afficher_section_avec_tableau(lines, "#### ICS", "##### Valeurs d'ICS par composant concerné")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# Afficher la sous-section "Valeurs d'ICS par composant"
if "##### Valeurs d'ICS par composant concerné" in details_content:
contenu_md = "##### Valeurs d'ICS par composant concerné\n"
# Afficher le résumé ICS moyen
for line in lines:
if "**ICS moyen" in line:
contenu_md += line
break
contenu_md += "\n"
contenu_md += afficher_section_avec_tableau(lines, "##### Valeurs d'ICS par composant concerné", "**ICS moyen")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
else:
contenu_bloc += contenu_md
# 2. Afficher la section IVC complète
if "#### IVC" in details_content:
contenu_md = "#### IVC\n"
# Afficher tous les détails de la section IVC
contenu_md += afficher_section_texte(lines, "#### IVC", "#### Vulnérabilité combinée ICS-IVC")
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
return None
else:
contenu_bloc += contenu_md
return contenu_bloc

View File

@ -0,0 +1,379 @@
import streamlit as st
import matplotlib.pyplot as plt
from app.plan_d_action.utils.data import (
PRECONISATIONS,
INDICATEURS,
poids_operation,
parse_chains_md,
set_vulnerability,
colorer_couleurs,
afficher_bloc_ihh_isg,
afficher_description,
afficher_caracteristiques_minerai,
initialiser_seuils
)
def calcul_poids_chaine(poids_A: int, poids_F: int, poids_T: int, poids_E: int, poids_M: int) -> tuple[str,dict,int]:
poids_total = (\
poids_A * poids_operation["Assemblage"] + \
poids_F * poids_operation["Fabrication"] + \
poids_T * poids_operation["Traitement"] + \
poids_E * poids_operation["Extraction"] + \
poids_M * poids_operation["Substitution"] \
) / sum(poids_operation.values())
if poids_total < 3:
criticite_chaine = "Modérée"
niveau_criticite = {"Facile"}
elif poids_total < 6:
criticite_chaine = "Élevée"
niveau_criticite = {"Facile", "Modérée"}
else:
criticite_chaine = "Critique"
niveau_criticite = {"Facile", "Modérée", "Difficile"}
return criticite_chaine, niveau_criticite, poids_total
def analyser_chaines(chaines: list[dict], produits: dict, composants: dict, mineraux: dict, seuils: dict, top_n: int = 0) -> list[tuple[str, str, int]]:
resultats = []
for chaine in chaines:
sel_prod = chaine["produit"]
sel_comp = chaine["composant"]
sel_miner = chaine["minerai"]
poids_A, *_ = set_vulnerability(produits[sel_prod]["IHH_Assemblage"], produits[sel_prod]["ISG_Assemblage"], "IHH", "ISG", seuils)
poids_F, *_ = set_vulnerability(composants[sel_comp]["IHH_Fabrication"], composants[sel_comp]["ISG_Fabrication"], "IHH", "ISG", seuils)
poids_T, *_ = set_vulnerability(mineraux[sel_miner]["IHH_Traitement"], mineraux[sel_miner]["ISG_Traitement"], "IHH", "ISG", seuils)
poids_E, *_ = set_vulnerability(mineraux[sel_miner]["IHH_Extraction"], mineraux[sel_miner]["ISG_Extraction"], "IHH", "ISG", seuils)
poids_M, *_ = set_vulnerability(mineraux[sel_miner]["ICS"], mineraux[sel_miner]["IVC"], "ICS", "IVC", seuils)
criticite_chaine, niveau_criticite, poids_total = calcul_poids_chaine(
poids_A, poids_F, poids_T, poids_E, poids_M
)
resultats.append({
"chaine": chaine,
"criticite_chaine": criticite_chaine,
"niveau_criticite": niveau_criticite,
"poids_total": poids_total
})
# Tri décroissant
resultats.sort(key=lambda x: x["poids_total"], reverse=True)
# Si top_n n'est pas spécifié, tout est retourné
if top_n == 0 or top_n >= len(resultats):
return resultats
# Déterminer le seuil de coupure
seuil_poids = resultats[top_n - 1]["poids_total"]
# Inclure tous ceux dont le poids est égal au seuil
top_resultats = [r for r in resultats if r["poids_total"] >= seuil_poids]
return top_resultats
def tableau_de_bord(chains: list[dict], produits: dict, composants: dict, mineraux: dict, seuils: dict):
col_left, col_right = st.columns([2, 3], gap="small", border=True)
with col_left:
st.markdown("**<u>Panneau de sélection</u>**", unsafe_allow_html=True)
produits_disponibles = sorted({c["produit"] for c in chains})
sel_prod = st.selectbox("Produit", produits_disponibles, index=produits_disponibles.index(st.session_state.sel_prod) if st.session_state.sel_prod else 0)
if sel_prod != st.session_state.sel_prod:
st.session_state.sel_prod = sel_prod
st.session_state.sel_comp = None
st.session_state.sel_miner = None
st.rerun()
composants_dispo = sorted({c["composant"] for c in chains if c["produit"] == sel_prod})
sel_comp = st.selectbox("Composant", composants_dispo, index=composants_dispo.index(st.session_state.sel_comp) if st.session_state.sel_comp else 0)
if sel_comp != st.session_state.sel_comp:
st.session_state.sel_comp = sel_comp
st.session_state.sel_miner = None
st.rerun()
mineraux_dispo = sorted({c["minerai"] for c in chains if c["produit"] == sel_prod and c["composant"] == sel_comp})
sel_miner = st.selectbox("Minerai", mineraux_dispo, index=mineraux_dispo.index(st.session_state.sel_miner) if st.session_state.sel_miner else 0)
if sel_miner != st.session_state.sel_miner:
st.session_state.sel_miner = sel_miner
st.rerun()
with col_right:
top_chains = analyser_chaines(chains, produits, composants, mineraux, seuils, top_n=5)
st.markdown("**<u>Top chaînes critiques pour sélection rapide</u>**", unsafe_allow_html=True)
for i, entry in enumerate(top_chains):
ch = entry["chaine"]
poids = entry["poids_total"]
criticite = entry["criticite_chaine"]
if st.button(f"**{ch['produit']} <-> {ch['composant']} <-> {ch['minerai']}** : {poids:.2f}{criticite}", key=f"select_{i}"):
st.session_state.sel_prod = ch["produit"]
st.session_state.sel_comp = ch["composant"]
st.session_state.sel_miner = ch["minerai"]
st.rerun()
c1, c2 = st.columns([3, 2], gap="small", border=True, vertical_alignment='center')
with c1:
st.markdown("**<u>Synthèse des criticités</u>**", unsafe_allow_html=True)
poids_A, couleur_A, couleur_A_ihh, couleur_A_isg = set_vulnerability(produits[sel_prod]["IHH_Assemblage"], produits[sel_prod]["ISG_Assemblage"], "IHH", "ISG", seuils)
poids_F, couleur_F, couleur_F_ihh, couleur_F_isg = set_vulnerability(composants[sel_comp]["IHH_Fabrication"], composants[sel_comp]["ISG_Fabrication"], "IHH", "ISG", seuils)
poids_T, couleur_T, couleur_T_ihh, couleur_T_isg = set_vulnerability(mineraux[sel_miner]["IHH_Traitement"], mineraux[sel_miner]["ISG_Traitement"], "IHH", "ISG", seuils)
poids_E, couleur_E, couleur_E_ihh, couleur_E_isg = set_vulnerability(mineraux[sel_miner]["IHH_Extraction"], mineraux[sel_miner]["ISG_Extraction"], "IHH", "ISG", seuils)
poids_M, couleur_M, couleur_M_ics, couleur_M_ivc = set_vulnerability(mineraux[sel_miner]["ICS"], mineraux[sel_miner]["IVC"], "ICS", "IVC", seuils)
st.markdown(f"* **{sel_prod} - Assemblage** : {colorer_couleurs(couleur_A)} ({poids_A})")
st.markdown(f"* **{sel_comp} - Fabrication** : {colorer_couleurs(couleur_F)} ({poids_F})")
st.markdown(f"* **{sel_miner} - Traitement** : {colorer_couleurs(couleur_T)} ({poids_T})")
st.markdown(f"* **{sel_miner} - Extraction** : {colorer_couleurs(couleur_E)} ({poids_E})")
st.markdown(f"* **{sel_miner} - Minerai** : {colorer_couleurs(couleur_M)} ({poids_M})")
criticite_chaine, niveau_criticite, poids_total = calcul_poids_chaine(poids_A, poids_F, poids_T, poids_E, poids_M)
with c2:
st.error(f"**Criticité globale : {criticite_chaine} ({poids_total})**")
return (
sel_prod, sel_comp, sel_miner, niveau_criticite,
couleur_A, poids_A, couleur_F, poids_F, couleur_T, poids_T, couleur_E, poids_E, couleur_M, poids_M,
couleur_A_ihh, couleur_A_isg, couleur_F_ihh, couleur_F_isg, couleur_T_ihh, couleur_T_isg,couleur_E_ihh, couleur_E_isg, couleur_M_ics, couleur_M_ivc
)
def afficher_criticites(produits: dict, composants: dict, mineraux: dict, sel_prod: str, sel_comp: str, sel_miner: str, seuils: dict) -> None:
with st.expander("Vue densemble des criticités", expanded=True):
st.markdown("## Vue densemble des criticités", unsafe_allow_html=True)
col_left, col_right = st.columns([1, 1], gap="small", border=True)
with col_left:
fig1, ax1 = plt.subplots(figsize=(2, 2.4))
ax1.scatter([produits[sel_prod]["ISG_Assemblage"]], [produits[sel_prod]["IHH_Assemblage"]], label="Assemblage", s=5)
ax1.scatter([composants[sel_comp]["ISG_Fabrication"]], [composants[sel_comp]["IHH_Fabrication"]], label="Fabrication", s=5)
ax1.scatter([mineraux[sel_miner]["ISG_Extraction"]], [mineraux[sel_miner]["IHH_Extraction"]], label="Extraction", s=5)
ax1.scatter([mineraux[sel_miner]["ISG_Traitement"]], [mineraux[sel_miner]["IHH_Traitement"]], label="Traitement", s=5)
# Seuils ISG (vertical)
ax1.axvline(seuils["ISG"]["vert"]["max"], linestyle='--', color='green', alpha=0.7, linewidth=0.5) # Seuil vert-orange
ax1.axvline(seuils["ISG"]["rouge"]["min"], linestyle='--', color='red', alpha=0.7, linewidth=0.5) # Seuil orange-rouge
# Seuils IHH (horizontal)
ax1.axhline(seuils["IHH"]["vert"]["max"], linestyle='--', color='green', alpha=0.7, linewidth=0.5) # Seuil vert-orange
ax1.axhline(seuils["IHH"]["rouge"]["min"], linestyle='--', color='red', alpha=0.7, linewidth=0.5) # Seuil orange-rouge
ax1.set_xlim(0, 100)
ax1.set_ylim(0, 100)
ax1.set_xlabel("ISG", fontsize=4)
ax1.set_ylabel("IHH", fontsize=4)
ax1.tick_params(axis='both', which='major', labelsize=4)
ax1.legend(bbox_to_anchor=(0.5, -0.25), loc='upper center', fontsize=4)
plt.tight_layout()
st.pyplot(fig1)
with col_right:
fig2, ax2 = plt.subplots(figsize=(2, 2.1))
ax2.scatter([mineraux[sel_miner]["IVC"]], [mineraux[sel_miner]["ICS"]], color='green', s=5, label=sel_miner)
# Seuils IVC (vertical)
ax2.axvline(seuils["IVC"]["vert"]["max"], linestyle='--', color='green', alpha=0.7, linewidth=0.5) # Seuil vert-orange
ax2.axvline(seuils["IVC"]["rouge"]["min"], linestyle='--', color='red', alpha=0.7, linewidth=0.5) # Seuil orange-rouge
# Seuils ICS (horizontal)
ax2.axhline(seuils["ICS"]["vert"]["max"], linestyle='--', color='green', alpha=0.7, linewidth=0.5) # Seuil vert-orange
ax2.axhline(seuils["ICS"]["rouge"]["min"], linestyle='--', color='red', alpha=0.7, linewidth=0.5) # Seuil orange-rouge
ax2.set_xlim(0, max(100, mineraux[sel_miner]["IVC"]))
ax2.set_ylim(0, 1)
ax2.set_xlabel("IVC", fontsize=4)
ax2.set_ylabel("ICS", fontsize=4)
ax2.tick_params(axis='both', which='major', labelsize=4)
ax2.legend(bbox_to_anchor=(0.5, -0.25), loc='upper center', fontsize=4)
plt.tight_layout()
st.pyplot(fig2)
st.markdown(f"""
Les lignes pointillées en {colorer_couleurs("vert")} ou {colorer_couleurs("rouge")} représentent les seuils des indices concernés.\n
Les indices ISG (stabilité géopolitique) et IVC (concurrence intersectorielle) influent sur la probabilité de survenance d'un risque.\n
Les indices IHH (concentration géographique) et ICS (capacité de substitution) influent sur le niveau d'impact d'un risque.\n
Une opération se trouvant au-dessus des deux seuils a donc une forte probabilité d'être impactée avec un niveau élevé sur l'incapacité à continuer la production.
""")
def afficher_explications_et_details(
couleur_A, poids_A, couleur_F, poids_F, couleur_T, poids_T, couleur_E, poids_E, couleur_M, poids_M,
produits, composants, mineraux, sel_prod, sel_comp, sel_miner,
couleur_A_ihh, couleur_A_isg, couleur_F_ihh, couleur_F_isg, couleur_T_ihh, couleur_T_isg,couleur_E_ihh, couleur_E_isg, couleur_M_ics, couleur_M_ivc, ui = True) -> str|None:
with st.expander("Explications et détails", expanded = True):
from collections import Counter
couleurs = [couleur_A, couleur_F, couleur_T, couleur_E, couleur_M]
compte = Counter(couleurs)
nb_rouge = compte["Rouge"]
nb_orange = compte["Orange"]
nb_vert = compte["Vert"]
contenu_md = f"""
Pour cette chaîne :blue-background[**{sel_prod} <-> {sel_comp} <-> {sel_miner}**], avec {nb_rouge} criticité(s) de niveau {colorer_couleurs("Rouge")}, {nb_orange} {colorer_couleurs("Orange")} et {nb_vert} {colorer_couleurs("Vert")}, les indices individuels par opération sont :
* **{sel_prod} - Assemblage** : {colorer_couleurs(couleur_A)} ({poids_A})
* IHH = {produits[sel_prod]["IHH_Assemblage"]} ({colorer_couleurs(couleur_A_ihh)}) <-> ISG = {produits[sel_prod]["ISG_Assemblage"]} ({colorer_couleurs(couleur_A_isg)})
* pondération de l'Assemblage dans le calcul de la criticité globale : 1,5
* se référer à **{sel_prod} et Assemblage** plus bas pour le détail complet
* **{sel_comp} - Fabrication** : {colorer_couleurs(couleur_F)} ({poids_F})
* IHH = {composants[sel_comp]["IHH_Fabrication"]} ({colorer_couleurs(couleur_F_ihh)}) <-> ISG = {composants[sel_comp]["ISG_Fabrication"]} ({colorer_couleurs(couleur_F_isg)})
* pondération de la Fabrication dans le calcul de la criticité globale : 2
* se référer à **{sel_comp} et Fabrication** plus bas pour le détail complet
* **{sel_miner} - Traitement** : {colorer_couleurs(couleur_A)} ({poids_A})
* IHH = {mineraux[sel_miner]["IHH_Traitement"]} ({colorer_couleurs(couleur_T_ihh)}) <-> ISG = {mineraux[sel_miner]["ISG_Traitement"]} ({colorer_couleurs(couleur_T_isg)})
* pondération du Traitement dans le calcul de la criticité globale : 1,5
* se référer à **{sel_miner} Vue globale** plus bas pour le détail complet de l'ensemble du minerai
* **{sel_miner} - Extraction** : {colorer_couleurs(couleur_E)} ({poids_E})
* IHH = {mineraux[sel_miner]["IHH_Extraction"]} ({colorer_couleurs(couleur_E_ihh)}) <-> ISG = {mineraux[sel_miner]["ISG_Extraction"]} ({colorer_couleurs(couleur_E_isg)})
* pondération de l'Extraction dans le calcul de la criticité globale : 1
* **{sel_miner} - Minerai** : {colorer_couleurs(couleur_M)} ({poids_M})
* ICS = {mineraux[sel_miner]["ICS"]} ({colorer_couleurs(couleur_M_ics)}) <-> IVC = {mineraux[sel_miner]["IVC"]} ({colorer_couleurs(couleur_M_ivc)})
* pondération de la Substitution dans le calcul de la criticité globale : 2
"""
contenu_md += "\n"
if ui:
st.markdown(contenu_md)
return None
else:
return contenu_md
def afficher_preconisations_et_indicateurs_generiques(niveau_criticite: dict, poids_A: int, poids_F: int, poids_T: int, poids_E: int, poids_M: int, ui: bool = True) -> tuple[str|None,str|None]:
contenu_md_left = "### Préconisations :\n\n"
contenu_md_left += "Mise en œuvre : \n"
for niveau, contenu in PRECONISATIONS.items():
if niveau in niveau_criticite:
contenu_md_left += f"* {colorer_couleurs(niveau)}\n"
for p in PRECONISATIONS[niveau]:
contenu_md_left += f" - {p}\n"
contenu_md_right = "### Indicateurs :\n\n"
contenu_md_right += "Mise en œuvre : \n"
for niveau, contenu in INDICATEURS.items():
if niveau in niveau_criticite:
contenu_md_right += f"* {colorer_couleurs(niveau)}\n"
for p in INDICATEURS[niveau]:
contenu_md_right += f" - {p}\n"
if ui:
with st.expander("Préconisations et indicateurs génériques"):
col_left, col_right = st.columns([1, 1], gap="small", border=True)
with col_left:
st.markdown(contenu_md_left)
with col_right:
st.markdown(contenu_md_right)
return None, None
else:
return contenu_md_left, contenu_md_right
def afficher_preconisations_specifiques(operation: str, niveau_criticite_operation: dict) -> str:
contenu_md = "#### Préconisations :\n\n"
contenu_md += "Mise en œuvre : \n"
for niveau, contenu in PRECONISATIONS[operation].items():
if niveau in niveau_criticite_operation[operation]:
contenu_md += f"* {colorer_couleurs(niveau)}\n"
for p in PRECONISATIONS[operation][niveau]:
contenu_md += f" - {p}\n"
return(contenu_md)
def afficher_indicateurs_specifiques(operation: str, niveau_criticite_operation: dict) -> str:
contenu_md = "#### Indicateurs :\n\n"
contenu_md += "Mise en œuvre : \n"
for niveau, contenu in INDICATEURS[operation].items():
if niveau in niveau_criticite_operation[operation]:
contenu_md += f"* {colorer_couleurs(niveau)}\n"
for p in INDICATEURS[operation][niveau]:
contenu_md += f" - {p}\n"
return(contenu_md)
def afficher_preconisations_et_indicateurs_specifiques(sel_prod: str, sel_comp: str, sel_miner: str, niveau_criticite_operation: dict) -> None:
for operation in ["Assemblage", "Fabrication", "Traitement", "Extraction"]:
if operation == "Assemblage":
item = sel_prod
elif operation == "Fabrication":
item = sel_comp
else:
item = sel_miner
with st.expander(f"Préconisations et indicateurs spécifiques - {operation}"):
st.markdown(f"### {operation} -> :blue-background[{item}]")
col_left, col_right = st.columns([1, 1], gap="small", border=True)
with col_left:
st.markdown(afficher_preconisations_specifiques(operation, niveau_criticite_operation))
with col_right:
st.markdown(afficher_indicateurs_specifiques(operation, niveau_criticite_operation))
def afficher_preconisations_et_indicateurs(niveau_criticite: dict, sel_prod: str, sel_comp: str, sel_miner: str, poids_A: int, poids_F: int, poids_T: int, poids_E: int, poids_M: int) -> None:
st.markdown("## Préconisations et indicateurs")
afficher_preconisations_et_indicateurs_generiques(niveau_criticite, poids_A, poids_F, poids_T, poids_E, poids_M)
def affectation_poids(poids_operation):
if poids_operation < 3:
niveau_criticite = {"Facile"}
elif poids_operation < 6:
niveau_criticite = {"Facile", "Modérée"}
else:
niveau_criticite = {"Facile", "Modérée", "Difficile"}
return niveau_criticite
niveau_criticite_operation = {}
niveau_criticite_operation["Assemblage"] = affectation_poids(poids_A)
niveau_criticite_operation["Fabrication"] = affectation_poids(poids_F)
niveau_criticite_operation["Traitement"] = affectation_poids(poids_T)
niveau_criticite_operation["Extraction"] = affectation_poids(poids_E)
afficher_preconisations_et_indicateurs_specifiques(sel_prod, sel_comp, sel_miner, niveau_criticite_operation)
def afficher_details_operations(produits, composants, mineraux, sel_prod, sel_comp, sel_miner, details_sections) -> None:
st.markdown("## Détails des opérations")
with st.expander(f"{sel_prod} et Assemblage"):
assemblage_details = details_sections.get(f"{sel_prod}_assemblage", "")
afficher_description(f"{sel_prod} et Assemblage", assemblage_details)
afficher_bloc_ihh_isg("Assemblage", produits[sel_prod]["IHH_Assemblage"], produits[sel_prod]["ISG_Assemblage"], assemblage_details)
with st.expander(f"{sel_comp} et Fabrication"):
fabrication_details = details_sections.get(f"{sel_comp}_fabrication", "")
afficher_description(f"{sel_comp} et Fabrication", fabrication_details)
afficher_bloc_ihh_isg("Fabrication", composants[sel_comp]["IHH_Fabrication"], composants[sel_comp]["ISG_Fabrication"], fabrication_details)
with st.expander(f"{sel_miner} — Vue globale"):
minerai_general = details_sections.get(f"{sel_miner}_general", "")
afficher_description(f"{sel_miner} — Vue globale", minerai_general)
extraction_details = details_sections.get(f"{sel_miner}_extraction", "")
afficher_bloc_ihh_isg("Extraction", mineraux[sel_miner]["IHH_Extraction"], mineraux[sel_miner]["ISG_Extraction"], extraction_details)
traitement_details = details_sections.get(f"{sel_miner}_traitement", "").removesuffix("\n---\n")
afficher_bloc_ihh_isg("Traitement", mineraux[sel_miner]["IHH_Traitement"], mineraux[sel_miner]["ISG_Traitement"], traitement_details)
afficher_caracteristiques_minerai(sel_miner, mineraux[sel_miner], minerai_general)
def initialiser_interface(filepath: str, config_path: str = "assets/config.yaml") -> None:
produits, composants, mineraux, chains, descriptions, details_sections = parse_chains_md(filepath)
if not chains:
st.warning("Aucune chaîne critique trouvée dans le fichier.")
return
seuils = initialiser_seuils(config_path)
sel_prod, sel_comp, sel_miner, niveau_criticite, \
couleur_A, poids_A, couleur_F, poids_F, couleur_T, poids_T, couleur_E, poids_E, couleur_M, poids_M, \
couleur_A_ihh, couleur_A_isg, couleur_F_ihh, couleur_F_isg, couleur_T_ihh, couleur_T_isg,couleur_E_ihh, couleur_E_isg, couleur_M_ics, couleur_M_ivc \
= tableau_de_bord(chains, produits, composants, mineraux, seuils)
afficher_criticites(produits, composants, mineraux, sel_prod, sel_comp, sel_miner, seuils)
afficher_explications_et_details(
couleur_A, poids_A, couleur_F, poids_F, couleur_T, poids_T, couleur_E, poids_E, couleur_M, poids_M,
produits, composants, mineraux, sel_prod, sel_comp, sel_miner,
couleur_A_ihh, couleur_A_isg, couleur_F_ihh, couleur_F_isg, couleur_T_ihh, couleur_T_isg,couleur_E_ihh, couleur_E_isg, couleur_M_ics, couleur_M_ivc)
afficher_preconisations_et_indicateurs(niveau_criticite, sel_prod, sel_comp, sel_miner, poids_A, poids_F, poids_T, poids_E, poids_M)
afficher_details_operations(produits, composants, mineraux, sel_prod, sel_comp, sel_miner, details_sections)

View File

@ -0,0 +1,5 @@
from .config import CORRESPONDANCE_COULEURS
__all__ = [
"CORRESPONDANCE_COULEURS"
]

View File

@ -0,0 +1,27 @@
from pathlib import Path
CORRESPONDANCE_COULEURS = {
"Rouge": "red",
"Orange": "orange",
"Vert": "green",
"FAIBLE": "green",
"MODÉRÉE": "orange",
"ÉLEVÉE à CRITIQUE": "red"
}
niveau_labels = {
0: "Produit final",
1: "Composant",
2: "Minerai",
10: "Opération",
11: "Pays d'opération",
12: "Acteur d'opération",
99: "Pays géographique"
}
# Répertoire courant du script
CURRENT_DIR = Path(__file__).resolve().parent.parent
# Répertoire "jobs" dans app/plan_d_action
JOBS = CURRENT_DIR / "jobs"
JOBS.mkdir(exist_ok=True)

View File

@ -0,0 +1,65 @@
from typing import Dict, Tuple, Union, List
import networkx as nx
def exporter_graphe_filtre(
G: nx.DiGraph,
liens_chemins: List[Tuple[Union[str, int], Union[str, int]]]
) -> nx.DiGraph:
"""Gère l'export du graphe filtré au format DOT.
Args:
G (nx.DiGraph): Le graphe d'origine à exporter.
liens_chemins (list): Liste des tuples contenant les paires de nœuds reliées
par un chemin dans le graphe, avec leurs attributs associés.
Returns:
tuple: Un tuple contenant le graphe exporté sous forme de DiGraph
et le dictionnaire des attributs du graphe exporté.
"""
G_export = nx.DiGraph()
for u, v in liens_chemins:
G_export.add_node(u, **G.nodes[u])
G_export.add_node(v, **G.nodes[v])
data = G.get_edge_data(u, v)
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
G_export.add_edge(u, v, **data[0])
elif isinstance(data, dict):
G_export.add_edge(u, v, **data)
else:
G_export.add_edge(u, v)
return(G_export)
def extraire_liens_filtres(
chemins: List[List[Union[str, int]]],
niveaux: Dict[str | int, int],
niveau_depart: int,
niveau_arrivee: int,
niveaux_speciaux: list[int]
) -> List[Tuple[Union[str, int], Union[str, int]]]:
"""Extrait les liens des chemins en respectant les niveaux.
Args:
chemins (list): Liste des chemins dans le graphe.
niveaux (dict): Dictionnaire associant chaque nœud au niveau correspondant.
niveau_depart (int): Niveau de départ pour la sélection des liens.
niveau_arrivee (int): Niveau d'arrivée pour la sélection des liens.
niveaux_speciaux (set): Ensemble des niveaux spéciaux à considérer dans le filtrage.
Returns:
set: Ensemble des paires de nœuds constituant les liens du graphe filtré
en respectant les niveaux et les niveaux spéciaux demandés.
"""
liens = set()
for chemin in chemins:
for i in range(len(chemin) - 1):
u, v = chemin[i], chemin[i + 1]
niveau_u = niveaux.get(u, 999)
niveau_v = niveaux.get(v, 999)
if (
(niveau_depart <= niveau_u <= niveau_arrivee or niveau_u in niveaux_speciaux)
and (niveau_depart <= niveau_v <= niveau_arrivee or niveau_v in niveaux_speciaux)
):
liens.add((u, v))
return liens

View File

@ -0,0 +1,19 @@
from typing import Dict
import networkx as nx
def extraire_niveaux(G: nx.DiGraph) -> Dict[str | int, int]:
"""Extrait les niveaux des nœuds du graphe.
Args:
G (nx.DiGraph): Le graphe d'origine à analyser.
Returns:
dict: Un dictionnaire associant chaque nœud au niveau correspondant.
Les valeurs sont des entiers représentant les niveaux.
"""
niveaux = {}
for node, attrs in G.nodes(data=True):
niveau_str = attrs.get("niveau")
if niveau_str:
niveaux[node] = int(str(niveau_str).strip('"'))
return niveaux

View File

@ -0,0 +1,23 @@
from typing import Dict, Tuple, Union
import networkx as nx
def preparer_graphe(G: nx.DiGraph) -> Tuple[nx.DiGraph, Dict[Union[str, int], int]]:
"""Nettoie et prépare le graphe pour l'analyse.
Args:
G (nx.DiGraph): Le graphe d'origine à nettoyer.
Returns:
tuple: Un tuple contenant le graphe nettoyé et les niveaux temporels associés
aux nœuds du graphe. Le graphe nettoyé a eu ses nœuds qui ne respectaient
pas les critères d'admissibilité enlevés.
"""
niveaux_temp = {
node: int(str(attrs.get("niveau")).strip('"'))
for node, attrs in G.nodes(data=True)
if attrs.get("niveau") and str(attrs.get("niveau")).strip('"').isdigit()
}
G.remove_nodes_from([n for n in G.nodes() if n not in niveaux_temp])
G.remove_nodes_from(
[n for n in G.nodes() if niveaux_temp.get(n) == 10 and 'Reserves' in n])
return G, niveaux_temp

View File

@ -0,0 +1,156 @@
from typing import Any, Dict, Tuple, List, Union, Optional
import streamlit as st
import networkx as nx
from utils.translations import _
from utils.persistance import maj_champ_statut, get_champ_statut, supprime_champ_statut
from utils.graph_utils import (
extraire_chemins_depuis,
extraire_chemins_vers
)
def selectionner_minerais(G: nx.Graph, noeuds_depart: List[Any]) -> List[Union[str, int]]:
"""Interface pour sélectionner les minerais si nécessaire.
Args:
G (nx.Graph): Le graphe des relations entre les nœuds.
noeuds_depart (list): Les nœuds de départ qui doivent être considérés.
Returns:
list: La liste des nœuds sélectionnés comme minerais.
"""
minerais_selection = None
st.markdown(f"## {str(_('pages.plan_d_action.select_minerals'))}")
# Étape 1 : récupérer tous les nœuds descendants depuis les produits finaux
descendants = set()
for start in noeuds_depart:
descendants.update(nx.descendants(G, start)) # tous les successeurs (récursifs)
# Étape 2 : ne garder que les nœuds de niveau 2 parmi les descendants
minerais_nodes = sorted([
n for n in descendants
if G.nodes[n].get("niveau") and int(str(G.nodes[n].get("niveau")).strip('"')) == 2
])
# Initialiser depuis champ_statut si besoin
if "analyse_minerais" not in st.session_state:
anciens = []
i = 0
while True:
val = get_champ_statut(f"pages.plan_d_action.filter_by_minerals.{i}")
if not val:
break
anciens.append(val)
i += 1
st.session_state["analyse_minerais"] = anciens
# Afficher le multiselect
st.multiselect(
str(_("pages.plan_d_action.filter_by_minerals")),
minerais_nodes,
key="analyse_minerais"
)
minerais_selection = st.session_state["analyse_minerais"]
# Toujours purger, puis réécrire si nécessaire
supprime_champ_statut("pages.plan_d_action.filter_by_minerals")
if minerais_selection:
for i, val in enumerate(minerais_selection):
maj_champ_statut(f"pages.plan_d_action.filter_by_minerals.{i}", val)
return minerais_selection
def selectionner_noeuds(
G: nx.Graph,
niveaux_temp: Dict[Union[str, int], int],
niveau_depart: int
) -> Tuple[Optional[List[Union[str, int]]], List[Union[str, int]]]:
"""Interface pour sélectionner les nœuds spécifiques de départ et d'arrivée.
Args:
G (nx.Graph): Le graphe des relations entre les nœuds.
niveaux_temp (dict): Dictionnaire contenant les niveaux des nœuds.
niveau_depart (int): Niveau à partir duquel commencer la sélection.
Returns:
tuple: La paire de départ et d'arrivée des nœuds sélectionnés.
"""
st.markdown("---")
st.markdown(f"## {str(_('pages.plan_d_action.fine_selection'))}")
depart_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_depart]
noeuds_arrivee = [n for n in G.nodes() if niveaux_temp.get(n) == 99]
if "analyse_noeuds_depart" not in st.session_state:
anciens = []
i = 0
while True:
val = get_champ_statut(f"pages.plan_d_action.filter_start_nodes.{i}")
if not val:
break
anciens.append(val)
i += 1
st.session_state["analyse_noeuds_depart"] = anciens
st.multiselect(
str(_("pages.plan_d_action.filter_start_nodes")),
sorted(depart_nodes),
key="analyse_noeuds_depart"
)
noeuds_depart = st.session_state["analyse_noeuds_depart"]
supprime_champ_statut("pages.plan_d_action.filter_start_nodes")
if noeuds_depart:
for i, val in enumerate(noeuds_depart):
maj_champ_statut(f"pages.plan_d_action.filter_start_nodes.{i}", val)
noeuds_depart = noeuds_depart if noeuds_depart else None
return noeuds_depart, noeuds_arrivee
def extraire_chemins_selon_criteres(
G: nx.Graph,
niveaux: Dict[str | int, int],
niveau_depart: int,
noeuds_depart: Optional[List[Union[str, int]]],
noeuds_arrivee: List[Union[str, int]],
minerais: Optional[List[Union[str, int]]]
) -> List[List[str | int]]:
"""Extrait les chemins selon les critères spécifiés.
Args:
G (nx.Graph): Le graphe des relations entre les nœuds.
niveaux (dict): Dictionnaire contenant les niveaux des nœuds.
niveau_depart (int): Niveau à partir duquel commencer la sélection.
noeuds_depart (list, optional): Les nœuds de départ qui doivent être considérés.
noeuds_arrivee (list): Les nœuds d'arrivée qui doivent être inclus dans les chemins.
minerais (list, optional): La liste des nœuds sélectionnés comme minerais.
Returns:
list: Liste des chemins trouvés selon les critères spécifiés.
"""
chemins = []
if noeuds_depart and noeuds_arrivee:
for nd in noeuds_depart:
for na in noeuds_arrivee:
tous_chemins = extraire_chemins_depuis(G, nd)
chemins.extend([chemin for chemin in tous_chemins if na in chemin])
elif noeuds_depart:
for nd in noeuds_depart:
chemins.extend(extraire_chemins_depuis(G, nd))
elif noeuds_arrivee:
for na in noeuds_arrivee:
chemins.extend(extraire_chemins_vers(G, na, niveau_depart))
else:
sources_depart = [n for n in G.nodes() if niveaux.get(n) == niveau_depart]
for nd in sources_depart:
chemins.extend(extraire_chemins_depuis(G, nd))
if minerais:
chemins = [chemin for chemin in chemins if any(n in minerais for n in chemin)]
return chemins

View File

@ -0,0 +1,26 @@
from typing import Dict, Optional
import re
from app.plan_d_action.utils.interface import CORRESPONDANCE_COULEURS
def remplacer_par_badge(
markdown_text: str,
correspondance: Optional[Dict[str, str]] = CORRESPONDANCE_COULEURS
) -> str:
"""Remplace certains mots par des badges colorés dans un texte Markdown.
Args:
markdown_text (str): Le texte Markdown à modifier.
correspondance (dict, optional): Dictionnaire qui mappe les mots
à remplacer vers les couleurs de leurs badges. Si None, utilise
CORRESPONDANCE_COULEURS par défaut.
Returns:
str: Le texte Markdown modifié avec des badges ajoutés.
"""
# Échappe les mots à remplacer s'ils contiennent des accents ou espaces
for mot, couleur in correspondance.items():
# Utilise des bords de mots (\b) pour éviter les remplacements partiels
pattern = r'\b' + re.escape(mot) + r'\b'
remplacement = f":{couleur}-badge[{mot}]"
markdown_text = re.sub(pattern, remplacement, markdown_text)
return markdown_text

View File

@ -1,3 +1,5 @@
from typing import List, Dict, Optional, Any
import networkx as nx
import streamlit as st
import altair as alt
import numpy as np
@ -6,7 +8,17 @@ import pandas as pd
from utils.translations import _
def afficher_graphique_altair(df):
def afficher_graphique_altair(df: pd.DataFrame) -> None:
"""
Affiche un graphique Altair pour les données d'IHH.
Args:
df (pd.DataFrame): DataFrame contenant les données de IHH.
Notes:
Cette fonction crée un graphique interactif pour visualiser les
données d'IHH selon différentes catégories et niveaux de gravité.
"""
# Définir les catégories originales (en français) et leur ordre
categories_fr = ["Assemblage", "Fabrication", "Traitement", "Extraction"]
@ -55,8 +67,8 @@ def afficher_graphique_altair(df):
base = alt.Chart(df_cat).encode(
x=alt.X('ihh_pays:Q', title=str(_("pages.visualisations.axis_titles.ihh_countries"))),
y=alt.Y('ihh_acteurs:Q', title=str(_("pages.visualisations.axis_titles.ihh_actors"))),
size=alt.Size('criticite_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('criticite_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred']))
size=alt.Size('ics_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('ics_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred']))
)
points = base.mark_circle(opacity=0.6)
@ -89,7 +101,20 @@ def afficher_graphique_altair(df):
st.altair_chart(chart, use_container_width=True)
def creer_graphes(donnees):
def creer_graphes(donnees: Optional[List[Dict[str, Any]]]) -> None:
"""
Crée un graphique Altair pour les données d'IVC.
Args:
donnees (Optional[List[Dict[str, Any]]]): Liste des données d'IVC.
Returns:
None.
Notes:
Cette fonction traite les données d'IVC et crée un graphique
interactif pour visualiser la concentration des ressources.
"""
if not donnees:
st.warning(str(_("pages.visualisations.no_data")))
return
@ -162,7 +187,17 @@ def creer_graphes(donnees):
st.error(f"{str(_('errors.graph_creation_error'))} {e}")
def lancer_visualisation_ihh_criticite(graph):
def lancer_visualisation_ihh_ics(graph: nx.DiGraph) -> None:
"""
Lance une visualisation Altair pour les données d'IHH critique.
Args:
graph (nx.DiGraph): Le graphe NetworkX contenant les données de IHH.
Notes:
Cette fonction traite le graphe et crée un graphique Altair
pour visualiser les données d'IHH critique.
"""
try:
import networkx as nx
from utils.graph_utils import recuperer_donnees
@ -180,7 +215,17 @@ def lancer_visualisation_ihh_criticite(graph):
st.error(f"{str(_('errors.ihh_criticality_error'))} {e}")
def lancer_visualisation_ihh_ivc(graph):
def lancer_visualisation_ihh_ivc(graph: nx.DiGraph) -> None:
"""
Lance une visualisation Altair pour les données d'IVC.
Args:
graph (Annx.Graphy): Le graphe NetworkX contenant les données de IV C.
Notes:
Cette fonction traite le graphe et crée un graphique Altair
pour visualiser les données d'IV C.
"""
try:
from utils.graph_utils import recuperer_donnees_2
noeuds_niveau_2 = [

View File

@ -1,25 +1,42 @@
import streamlit as st
from utils.widgets import html_expander
from utils.translations import _
import networkx as nx
from .graphes import (
lancer_visualisation_ihh_criticite,
lancer_visualisation_ihh_ics,
lancer_visualisation_ihh_ivc
)
def interface_visualisations(G_temp, G_temp_ivc):
def interface_visualisations(G_temp: nx.DiGraph, G_temp_ivc: nx.DiGraph) -> None:
"""
Affiche l'interface utilisateur des visualisations.
Parameters
----------
G_temp : object
Graphique temporel contenant les données de IHH.
G_temp_ivc : object
Graphique temporel contenant les données d'IVC.
Notes
-----
Cette fonction initialise l'interface utilisateur qui permet aux utilisateurs de visualiser
différentes données relatives à la gravité et au risque d'infections.
Elle gère également le traitement des erreurs liées aux graphiques temporels IHH et IV C.
"""
st.markdown(f"# {str(_('pages.visualisations.title'))}")
with st.expander(str(_("pages.visualisations.help")), expanded=False):
st.markdown("\n".join(_("pages.visualisations.help_content")))
html_expander(f"{str(_('pages.visualisations.help'))}", content="\n".join(_("pages.visualisations.help_content")), open_by_default=False, details_class="details_introduction")
st.markdown("---")
st.markdown(f"""## {str(_("pages.visualisations.ihh_criticality"))}
{str(_("pages.visualisations.ihh_criticality_desc"))}
""")
if st.button(str(_("buttons.run")), key="btn_ihh_criticite"):
if st.button(str(_("buttons.run")), key="btn_ihh_ics", icon=":material/bubble_chart:"):
try:
lancer_visualisation_ihh_criticite(G_temp)
lancer_visualisation_ihh_ics(G_temp)
except Exception as e:
st.error(f"{str(_('errors.ihh_criticality_error'))} {e}")
@ -28,7 +45,7 @@ def interface_visualisations(G_temp, G_temp_ivc):
{str(_("pages.visualisations.ihh_ivc_desc"))}
""")
if st.button(str(_("buttons.run")), key="btn_ihh_ivc"):
if st.button(str(_("buttons.run")), key="btn_ihh_ivc", icon=":material/bubble_chart:"):
try:
lancer_visualisation_ihh_ivc(G_temp_ivc)
except Exception as e:

View File

@ -2,9 +2,12 @@
## Styles
Le fichier **styles.css** a été construit pour agir sur le styme produit par Streamlit ou pour décorer des éléments construits par fabnum.py
Le fichier **base.css** a été construit pour agir sur le style produit par Streamlit ou pour décorer des éléments construits par fabnum.py, indépendamment du thème choisi
Les deux fichiers **theme-light.css** et **theme-dark.css** contiennent les variables utilisées par base.css pour afficher les couleurs.
Il sera important de regarder s'il est possible d'interagir avec le css de Streamlit sans passer par des déclarations !important
Streamlit utilise le theme par défaut du système du poste de travail de l'internaute. Afin de maîtriser complètement le thème avec base.css, il est important que la configuration côté serveur de Streamlit soit forcée au thème light dans le fichier .streamlit/config.toml :
[theme]
base = "light"
## Icone
@ -37,4 +40,4 @@ La dernière colonne donne le label de l'item associé à la fiche.
Les labels associés aux fiches dans ce fichier doivent, bien évidemment, être **totalement égaux** aux labels de la gestion des tickets.
Ces labels avec en plus la branche permettent de faire le lien exact entre une fiche présentée dans l'application et le système de gestion des tickets.
Ces labels avec en plus la branche permettent de faire le lien exact entre une fiche présentée dans l'application et le système de gestion des tickets.

View File

@ -1,11 +1,11 @@
version: 1.1
date: 2025-05-06
date: 2025-05-27
seuils:
IVC: # Indice de vulnérabilité concurrentielle
vert: { max: 5 }
orange: { min: 5, max: 15 }
rouge: { min: 15 }
vert: { max: 15 }
orange: { min: 15, max: 60 }
rouge: { min: 60 }
IHH: # Index Herfindahl-Hirschman
vert: { max: 15 }

View File

@ -1,276 +0,0 @@
{
"app": {
"title": "Fabnum Chain Analysis",
"description": "Ecosystem exploration and vulnerability identification.",
"dev_mode": "You are in the development environment."
},
"header": {
"title": "FabNum - Digital Manufacturing Chain",
"subtitle": "Ecosystem exploration and vulnerability identification."
},
"footer": {
"copyright": "Fabnum © 2025",
"contact": "Contact",
"license": "License",
"license_text": "CC BY-NC-ND",
"eco_note": "🌱 CO₂ calculations via",
"eco_provider": "The Green Web Foundation",
"powered_by": "🚀 Powered by",
"powered_by_name": "Streamlit"
},
"sidebar": {
"menu": "Main Menu",
"navigation": "Main Navigation",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_instructions_only": "Theme changes can only be made from the Instructions tab.",
"impact": "Environmental Impact",
"loading": "Loading..."
},
"auth": {
"title": "Authentication",
"username": "Username_token",
"token": "Gitea Personal Access Token",
"login": "Login",
"logout": "Logout",
"logged_as": "Logged in as",
"error": "❌ Access denied.",
"gitea_error": "❌ Unable to verify user with Gitea.",
"success": "Successfully logged out."
},
"navigation": {
"instructions": "Instructions",
"personnalisation": "Customization",
"analyse": "Analysis",
"visualisations": "Visualizations",
"fiches": "Cards"
},
"pages": {
"instructions": {
"title": "Instructions"
},
"personnalisation": {
"title": "Final Product Customization",
"help": "How to use this tab?",
"help_content": [
"1. Click on \"Add a final product\" to create a new product",
"2. Give your product a name",
"3. Select an appropriate assembly operation (if relevant)",
"4. Choose the components that make up your product from the list provided",
"5. Save your configuration for future reuse",
"6. You will be able to modify or delete your custom products later"
],
"add_new_product": "Add a new final product",
"new_product_name": "New product name (unique)",
"assembly_operation": "Assembly operation (optional)",
"none": "-- None --",
"components_to_link": "Components to link",
"create_product": "Create product",
"added": "added",
"modify_product": "Modify an added final product",
"products_to_modify": "Products to modify",
"delete": "Delete",
"linked_assembly_operation": "Linked assembly operation",
"components_linked_to": "Components linked to",
"update": "Update",
"updated": "updated",
"deleted": "deleted",
"save_restore_config": "Save or restore configuration",
"export_config": "Export configuration",
"download_json": "Download (JSON)",
"import_config": "Import a JSON configuration (max 100 KB)",
"file_too_large": "File too large (max 100 KB).",
"no_products_found": "No products found in the file.",
"select_products_to_restore": "Select products to restore",
"products_to_restore": "Products to restore",
"restore_selected": "Restore selected items",
"config_restored": "Partial configuration successfully restored.",
"import_error": "Import error:"
},
"analyse": {
"title": "Graph Analysis",
"help": "How to use this tab?",
"help_content": [
"1. Select the starting level (final product, component, or mineral)",
"2. Choose the desired destination level",
"3. Refine your selection by specifying either one or more specific minerals to target or specific items at each level (optional)",
"4. Define the analysis criteria by selecting the relevant vulnerability indices",
"5. Choose the index combination mode (AND/OR) according to your analysis needs",
"6. Explore the generated graph using zoom and panning controls; you can switch to full screen mode for the graph"
],
"selection_nodes": "Selection of start and end nodes",
"select_level": "-- Select a level --",
"start_level": "Start level",
"end_level": "End level",
"select_minerals": "Select one or more minerals",
"filter_by_minerals": "Filter by minerals (optional)",
"fine_selection": "Fine selection of items",
"filter_start_nodes": "Filter by start nodes (optional)",
"filter_end_nodes": "Filter by end nodes (optional)",
"vulnerability_filters": "Selection of filters to identify vulnerabilities",
"filter_ics": "Filter paths containing at least one critical mineral for a component (ICS > 66%)",
"filter_ivc": "Filter paths containing at least one critical mineral in relation to sectoral competition (IVC > 30)",
"filter_ihh": "Filter paths containing at least one critical operation in relation to geographical or industrial concentration (IHH countries or actors > 25)",
"apply_ihh_filter": "Apply IHH filter on:",
"countries": "Countries",
"actors": "Actors",
"filter_isg": "Filter paths containing an unstable country (ISG ≥ 60)",
"filter_logic": "Filter logic",
"or": "OR",
"and": "AND",
"run_analysis": "Run analysis",
"sankey": {
"no_paths": "No paths found for the specified criteria.",
"no_matching_paths": "No paths match the criteria.",
"filtered_hierarchy": "Hierarchy filtered by levels and nodes",
"download_dot": "Download filtered DOT file",
"relation": "Relation"
}
},
"visualisations": {
"title": "Visualizations",
"help": "How to use this tab?",
"help_content": [
"1. Explore the graphs presenting the Herfindahl-Hirschmann Index (IHH)",
"2. Analyze its relationship with the average criticality of minerals or their Competitive Vulnerability Index (IVC)",
"3. Zoom in on the graphs to better discover the information",
"",
"It is important to remember that the IHH has two thresholds:",
"* below 15, concentration is considered to be low",
"* above 25, it is considered to be high",
"",
"Thus, the higher a point is positioned in the top right of the graphs, the higher the risks.",
"The graphs present 2 horizontal and vertical lines to mark these thresholds."
],
"ihh_criticality": "Herfindahl-Hirschmann Index - IHH vs Criticality",
"ihh_criticality_desc": "The size of the points indicates the substitutability criticality of the mineral.",
"ihh_ivc": "Herfindahl-Hirschmann Index - IHH vs IVC",
"ihh_ivc_desc": "The size of the points indicates the competitive criticality of the mineral.",
"launch": "Launch",
"no_data": "No data to display.",
"categories": {
"assembly": "Assembly",
"manufacturing": "Manufacturing",
"processing": "Processing",
"extraction": "Extraction"
},
"axis_titles": {
"ihh_countries": "IHH Countries (%)",
"ihh_actors": "IHH Actors (%)",
"ihh_extraction": "IHH Extraction (%)",
"ihh_reserves": "IHH Reserves (%)"
},
"chart_titles": {
"concentration_criticality": "Concentration and Criticality {0}",
"concentration_resources": "Concentration of Critical Resources vs IVC Vulnerability"
}
},
"fiches": {
"title": "Card Discovery",
"help": "How to use this tab?",
"help_content": [
"1. Browse the list of available cards by category",
"2. Select a card to display its full content",
"3. Consult detailed data, graphs, and additional analyses",
"4. Use this information to deepen your understanding of the identified vulnerabilities",
"",
"The categories are as follows:",
"* Assembly: operation of assembling final products from components",
"* Related: various operations necessary to manufacture digital technology, but not directly entering its composition",
"* Criticalities: indices used to identify and evaluate vulnerabilities",
"* Manufacturing: operation of manufacturing components from minerals",
"* Mineral: description and operations of extraction and processing of minerals"
],
"no_files": "No cards available at the moment.",
"choose_category": "Choose a card category",
"select_folder": "-- Select a folder --",
"choose_file": "Choose a card",
"select_file": "-- Select a card --",
"loading_error": "Error loading the card:",
"download_pdf": "Download this card as PDF",
"pdf_unavailable": "The PDF file for this card is not available.",
"ticket_management": "Ticket management for this card",
"tickets": {
"create_new": "Create a new ticket linked to this card",
"model_load_error": "Unable to load the ticket template.",
"contribution_type": "Contribution type",
"specify": "Specify",
"other": "Other",
"concerned_card": "Concerned card",
"subject": "Subject of the proposal",
"preview": "Preview ticket",
"cancel": "Cancel",
"preview_title": "Ticket preview",
"summary": "Summary",
"title": "Title",
"labels": "Labels",
"confirm": "Confirm ticket creation",
"created": "Ticket created and form cleared.",
"model_error": "Template loading error:",
"no_linked_tickets": "No tickets linked to this card.",
"associated_tickets": "Tickets associated with this card",
"moderation_notice": "ticket(s) awaiting moderation are not displayed.",
"status": {
"awaiting": "Awaiting processing",
"in_progress": "In progress",
"completed": "Completed",
"rejected": "Rejected",
"others": "Others"
},
"no_title": "No title",
"unknown": "unknown",
"subject_label": "Subject",
"no_labels": "none",
"comments": "Comment(s):",
"no_comments": "No comments.",
"comment_error": "Error retrieving comments:",
"opened_by": "Opened by",
"on_date": "on",
"updated": "UPDATED"
}
}
},
"node_levels": {
"0": "Final product",
"1": "Component",
"2": "Mineral",
"10": "Operation",
"11": "Operation country",
"12": "Operation actor",
"99": "Geographic country"
},
"errors": {
"log_read_error": "Log reading error:",
"graph_preview_error": "Graph preview error:",
"graph_creation_error": "Error creating the graph:",
"ihh_criticality_error": "Error in IHH vs Criticality visualization:",
"ihh_ivc_error": "Error in IHH vs IVC visualization:",
"comment_fetch_error": "Error retrieving comments:",
"template_load_error": "Template loading error:",
"import_error": "Import error:"
},
"buttons": {
"download": "Download",
"run": "Run",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"filter": "Filter",
"search": "Search",
"create": "Create",
"update": "Update",
"delete": "Delete",
"preview": "Preview",
"export": "Export",
"import": "Import",
"restore": "Restore",
"browse_files": "Browse files"
},
"ui": {
"file_uploader": {
"drag_drop_here": "Drag and drop file here",
"size_limit": "100 KB limit per file • JSON"
}
}
}

View File

@ -6,10 +6,10 @@
},
"header": {
"title": "FabNum - Digital Manufacturing Chain",
"subtitle": "Ecosystem exploration and vulnerability identification."
"subtitle": "Ecosystem exploration and vulnerability identification.\n -> This site is still under development <-"
},
"footer": {
"copyright": "Fabnum © 2025",
"copyright": "FabNum@Polycrisis Observatory © 2025",
"contact": "Contact",
"license": "License",
"license_text": "CC BY-NC-ND",
@ -43,6 +43,8 @@
"instructions": "Instructions",
"personnalisation": "Customization",
"analyse": "Analysis",
"ia_nalyse": "AI'nalysis",
"plan_d_action": "Actions plan",
"visualisations": "Visualizations",
"fiches": "Cards"
},
@ -64,6 +66,7 @@
"add_new_product": "Add a new final product",
"new_product_name": "New product name (unique)",
"assembly_operation": "Assembly operation (optional)",
"product_exists": "This product already exists",
"none": "-- None --",
"components_to_link": "Components to link",
"create_product": "Create product",
@ -128,6 +131,45 @@
"relation": "Relation"
}
},
"ia_nalyse": {
"title": "Graph Analysis by AI",
"help": "How to use this tab?",
"help_content": [
"The graph covers all levels, from end products to geographic countries.\n",
"1. You can select minerals through which the paths go.",
"2. You can choose end products to perform an analysis tailored to your context.\n",
"These two selections are optional, but strongly recommended for a more relevant analysis.",
"The analysis is carried out using a private AI on a minimalist server. The result is therefore not immediate (approximately 30 minutes) and you will be notified of the progress."
],
"select_minerals": "Select one or more minerals",
"filter_by_minerals": "Filter by minerals (optional, but highly recommended)",
"fine_selection": "End product selection",
"filter_start_nodes": "Filter by start nodes (optional, but recommended)",
"run_analysis": "Run analysis",
"confirm_download": "Confirm download",
"submit_request": "Submit your request",
"empty_graph": "The graph is empty. Please make another selection."
},
"plan_d_action": {
"title": "Graph analysis for action",
"help": "How to use this tab?",
"help_content": [
"The graph covers all levels, from end products to geographic countries.\n",
"1. You can select minerals through which the paths go.",
"2. You can choose end products to perform an analysis tailored to your context.\n",
"These two selections are optional, but strongly recommended for a more relevant analysis.",
"The recommendations for actions and indicator monitoring are generic. They must therefore be adapted to the context.",
"The proposed actions or indicators depend on the type of organization concerned and can be applied directly or required of digital providers."
],
"select_minerals": "Select one or more minerals",
"filter_by_minerals": "Filter by minerals (optional, but highly recommended)",
"fine_selection": "End product selection",
"filter_start_nodes": "Filter by start nodes",
"run_analysis": "Run analysis",
"confirm_download": "Confirm download",
"submit_request": "Submit your request",
"empty_graph": "The graph is empty. Please make another selection."
},
"visualisations": {
"title": "Visualizations",
"help": "How to use this tab?",
@ -227,7 +269,11 @@
"comment_error": "Error retrieving comments:",
"opened_by": "Opened by",
"on_date": "on",
"updated": "UPDATED"
"updated": "UPDATED",
"continue": "Continuer",
"created_success": "Ticket created and placed in moderation",
"created_error": "Ticket creation failed. Please try later",
"see_ticket": "See ticket"
}
}
},
@ -265,6 +311,7 @@
"export": "Export",
"import": "Import",
"restore": "Restore",
"refresh": "Refresh",
"browse_files": "Browse files"
},
"ui": {
@ -272,5 +319,14 @@
"drag_drop_here": "Drag and drop file here",
"size_limit": "100 KB limit per file • JSON"
}
},
"batch": {
"in_queue": "In queue",
"in_progress": "Analysis in progress",
"failure": "Error",
"unknwon_error": "unknown error",
"no_task": "No task wainting or in progress",
"complete": "Analysis complete. Download the result in zip format, which contains the detailed report and analysis.",
"step": "Step"
}
}

View File

@ -6,10 +6,10 @@
},
"header": {
"title": "FabNum - Chaîne de fabrication du numérique",
"subtitle": "Parcours de l'écosystème et identification des vulnérabilités."
"subtitle": "Parcours de l'écosystème et identification des vulnérabilités.\n-> Ce site est encore en cours de développement <-"
},
"footer": {
"copyright": "Fabnum © 2025",
"copyright": "FabNum@Polycrisis Observatory © 2025",
"contact": "Contact",
"license": "Licence",
"license_text": "CC BY-NC-ND",
@ -43,6 +43,8 @@
"instructions": "Instructions",
"personnalisation": "Personnalisation",
"analyse": "Analyse",
"ia_nalyse": "IA'nalyse",
"plan_d_action": "Plan d'action",
"visualisations": "Visualisations",
"fiches": "Fiches"
},
@ -64,6 +66,7 @@
"add_new_product": "Ajouter un nouveau produit final",
"new_product_name": "Nom du nouveau produit (unique)",
"assembly_operation": "Opération d'assemblage (optionnelle)",
"product_exists": "Ce produit existe déjà.",
"none": "-- Aucune --",
"components_to_link": "Composants à lier",
"create_product": "Créer le produit",
@ -128,6 +131,45 @@
"relation": "Relation"
}
},
"ia_nalyse": {
"title": "Analyse du graphe par IA",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"Le graphe intègre l'ensemble des niveaux, des produits finaux aux pays géographiques.\n",
"1. Vous pouvez sélectionner des minerais par lesquels passent les chemins.",
"2. Vous pouvez choisir des produits finaux pour faire une analyse adaptée à votre contexte.\n",
" Ces deux sélections sont optionnelles, mais fortement recommandées pour avoir une meilleure pertinence de l'analyse.",
"L'analyse se réalise à l'aide d'une IA privée, sur un serveur minimaliste. Le résultat n'est donc pas immédiat (ordre de grandeur : 30 minutes) et vous serez informé de l'avancement."
],
"select_minerals": "Sélectionner un ou plusieurs minerais",
"filter_by_minerals": "Filtrer par minerais (optionnel, mais recommandé)",
"fine_selection": "Sélection des produits finaux",
"filter_start_nodes": "Filtrer par noeuds de départ (optionnel, mais recommandé)",
"run_analysis": "Lancer l'analyse",
"confirm_download": "Confirmer le téléchargement",
"submit_request": "Soumettre votre demande",
"empty_graph": "Le graphe est vide. Veuillez faire une autre sélection."
},
"plan_d_action": {
"title": "Analyse du graphe pour action",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"Le graphe intègre l'ensemble des niveaux, des produits finaux aux pays géographiques.\n",
"1. Vous pouvez sélectionner des minerais par lesquels passent les chemins.",
"2. Vous pouvez choisir des produits finaux pour faire une analyse adaptée à votre contexte.\n",
" Ces deux sélections sont optionnelles, mais fortement recommandées pour avoir une meilleure pertinence de l'analyse.",
"Les préconisations d'actions et de suivi d'indicateurs sont génériques. Elles doivent donc être adaptées au contexte.",
"Les actions ou les indicateurs proposés dépendent du type d'organisation concernée et peuvent être appliquées directement ou exigées des fournisseurs de numérique."
],
"select_minerals": "Sélectionner un ou plusieurs minerais",
"filter_by_minerals": "Filtrer par minerais (optionnel, mais recommandé)",
"fine_selection": "Sélection des produits finaux",
"filter_start_nodes": "Filtrer par noeuds de départ",
"run_analysis": "Lancer l'analyse",
"confirm_download": "Confirmer le téléchargement",
"submit_request": "Soumettre votre demande",
"empty_graph": "Le graphe est vide. Veuillez faire une autre sélection."
},
"visualisations": {
"title": "Visualisations",
"help": "Comment utiliser cet onglet ?",
@ -227,7 +269,11 @@
"comment_error": "Erreur lors de la récupération des commentaires :",
"opened_by": "Ouvert par",
"on_date": "le",
"updated": "MAJ"
"updated": "MAJ",
"continue": "Continuer",
"created_success": "Ticket créé et placé en modération",
"created_error": "Échec de la création du ticket. Veuillez réessayer plus tard",
"see_ticket": "Voir le ticket"
}
}
},
@ -265,6 +311,7 @@
"export": "Exporter",
"import": "Importer",
"restore": "Restaurer",
"refresh": "Rafraîchir",
"browse_files": "Parcourir les fichiers"
},
"ui": {
@ -272,5 +319,14 @@
"drag_drop_here": "Glissez-déposez votre fichier ici",
"size_limit": "Limite 100 Ko par fichier • JSON"
}
},
"batch": {
"in_queue": "En attente",
"in_progress": "Analyse en cours",
"failure": "Échec",
"unknwon_error": "erreur inconnue",
"no_task": "Aucune tâche en attente ou en cours",
"complete": "Analyse terminée. Télécharger le résultat au format zip, qui contient le rapport détaillé et l'analyse.",
"step": "Étape"
}
}

View File

@ -4,33 +4,33 @@
1. Reset et base
========================================== */
.stAppHeader {
visibility: hidden;
visibility: hidden;
}
body,
html {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
}
body,
.stApp,
.block-container {
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
}
/* ==========================================
2. Layout et containers
========================================== */
.block-container {
max-width: 1024px !important;
padding: 0 1rem 10rem;
max-width: 1024px !important;
padding: 0 1rem 10rem;
}
.stVerticalBlock {
gap: 0.5rem !important;
gap: 0.5rem !important;
}
/* ==========================================
@ -42,97 +42,97 @@ body,
.stDownloadButton > button,
.stFormSubmitButton > button,
.stSlider > div > div {
background-color: darkgreen !important;
color: white !important;
border: 1px solid grey;
background-color: #072c6e !important;
color: white !important;
border: 1px solid grey;
}
.st-key-FormSubmitter-auth_form-Se-connecter {
margin-left: auto;
margin-right: auto;
margin-left: auto;
margin-right: auto;
}
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-primary"],
button[data-testid="stBaseButton-primary"],
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-secondary"] {
color: white !important;
background: darkgreen !important;
button[data-testid="stBaseButton-secondary"] {
color: white !important;
background: #072c6e !important;
}
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-primary"]
p,
button[data-testid="stBaseButton-primary"]
p,
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-secondary"]
p {
color: white !important;
button[data-testid="stBaseButton-secondary"]
p {
color: white !important;
}
.bouton-fictif {
display: inline-flex;
-moz-box-align: center;
align-items: center;
-moz-box-pack: center;
justify-content: center;
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
min-height: 2.5rem;
margin-bottom: 20px;
line-height: 1;
text-transform: none;
font-size: x-large;
font-family: inherit;
user-select: none;
border: 1px solid rgba(49, 51, 63, 0.2);
background-color: darkgrey !important;
color: darkgreen !important;
font-weight: bold !important;
width: 100%;
letter-spacing: 0.2em;
display: inline-flex;
-moz-box-align: center;
align-items: center;
-moz-box-pack: center;
justify-content: center;
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
min-height: 2.5rem;
margin-bottom: 20px;
line-height: 1;
text-transform: none;
font-size: x-large;
font-family: inherit;
user-select: none;
border: 1px solid rgba(49, 51, 63, 0.2);
background-color: darkgrey !important;
color: #072c6e !important;
font-weight: bold !important;
width: 100%;
letter-spacing: 0.2em;
}
button[data-testid="stBaseButton-headerNoPadding"] svg {
fill: var(--text-color) !important;
fill: var(--text-color) !important;
}
/* --- 3.2 Onglets et radiogroup --- */
div[role="radiogroup"] > label {
padding: 0.5em 1em;
border-radius: 0.4em;
margin-right: 0.5em;
cursor: pointer;
border: 1px solid #fff;
padding: 0.5em 1em;
border-radius: 0.4em;
margin-right: 0.5em;
cursor: pointer;
border: 1px solid #fff;
}
div[role="radiogroup"] > label[data-selected="true"] {
font-weight: bold;
border: 2px solid #145a1a;
font-weight: bold;
border: 2px solid #145a1a;
}
section:not([data-testid="stSidebar"]) div[role="radiogroup"] > label p {
background-color: var(--radio-bg) !important;
color: var(--radio-text) !important;
background-color: var(--radio-bg) !important;
color: var(--radio-text) !important;
}
section:not([data-testid="stSidebar"])
div[role="radiogroup"]
> label[data-selected="true"] {
background-color: var(--radio-selected-bg) !important;
color: var(--radio-selected-text) !important;
div[role="radiogroup"]
> label[data-selected="true"] {
background-color: var(--radio-selected-bg) !important;
color: var(--radio-selected-text) !important;
}
/* --- 3.3 Champs de formulaire --- */
div[data-baseweb="select"],
section:not([data-testid="stSidebar"]) div[data-baseweb="base-input"],
section[data-testid="stFileUploaderDropzone"] {
border: 1px solid var(--input-border) !important;
border-radius: 5px;
padding: 4px;
border: 1px solid var(--input-border) !important;
border-radius: 5px;
padding: 4px;
}
small {
display: none;
display: none;
}
section:not([data-testid="stSidebar"]) div[data-testid="stSelectbox"] p,
@ -142,7 +142,7 @@ section:not([data-testid="stSidebar"]) div[data-testid="stCheckbox"] p,
section:not([data-testid="stSidebar"]) div[data-testid="stTextInput"] p,
section:not([data-testid="stSidebar"]) div[data-testid="stTextArea"] p,
section:not([data-testid="stSidebar"]) div[data-testid="stAlertContentInfo"] p {
color: var(--text-color) !important;
color: var(--text-color) !important;
}
/* ==========================================
@ -151,98 +151,98 @@ section:not([data-testid="stSidebar"]) div[data-testid="stAlertContentInfo"] p {
/* --- 4.1 Header --- */
.wide-header {
width: 100vw;
margin-left: calc(-50vw + 50%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid #ddd;
text-align: center;
padding-top: 1rem;
margin-top: -1.25em;
background-color: var(--header-bg);
width: 100vw;
margin-left: calc(-50vw + 50%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid #ddd;
text-align: center;
padding-top: 1rem;
margin-top: -1.25em;
background-color: var(--header-bg);
}
.titre-header {
font-size: 2rem !important;
font-weight: bolder !important;
color: var(--header-title);
font-size: 2rem !important;
font-weight: bolder !important;
color: var(--header-title);
}
/* --- 4.2 Footer --- */
.wide-footer {
width: 100vw;
margin-left: calc(-50vw + 50%);
margin-top: 3rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-top: 1px solid #ddd;
text-align: center;
padding-top: 1rem;
background-color: var(--footer-bg);
width: 100vw;
margin-left: calc(-50vw + 50%);
margin-top: 3rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-top: 1px solid #ddd;
text-align: center;
padding-top: 1rem;
background-color: var(--footer-bg);
}
.info-footer {
font-size: 1rem !important;
font-weight: 800;
color: var(--footer-text);
font-size: 1rem !important;
font-weight: 800;
color: var(--footer-text);
}
/* ==========================================
5. Sidebar
========================================== */
section[data-testid="stSidebar"] {
background-color: #ccc !important;
color: #111 !important;
background-color: #ccc !important;
color: #111 !important;
}
section[data-testid="stSidebar"] .stButton > button {
background-color: darkgreen !important;
color: white !important;
font-weight: bold !important;
border: 1px solid #ccc !important;
width: 100%;
background-color: #072c6e !important;
color: white !important;
font-weight: bold !important;
border: 1px solid #ccc !important;
width: 100%;
}
section[data-testid="stSidebar"] .decorative-heading {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
text-align: center;
color: #145a1a;
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
text-align: center;
color: #072c6e;
}
section[data-testid="stSidebar"] div[role="radiogroup"] {
justify-content: center !important;
display: flex !important;
gap: 1rem;
justify-content: center !important;
display: flex !important;
gap: 1rem;
}
/* ==========================================
6. Tables
========================================== */
table {
border: 1px solid var(--table-border) !important;
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5em;
border: 1px solid var(--table-border) !important;
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5em;
}
th,
td {
border: 1px solid var(--table-border) !important;
padding: 8px;
text-align: left;
border: 1px solid var(--table-border) !important;
padding: 8px;
text-align: left;
}
caption {
caption-side: top;
font-weight: bold;
padding: 0.5em;
text-align: left;
caption-side: bottom;
text-align: center;
caption-side: top;
font-weight: bold;
padding: 0.5em;
text-align: left;
caption-side: bottom;
text-align: center;
}
table[role="table"] th[scope="col"] {
background-color: var(--background-color);
background-color: var(--background-color);
}
/* ==========================================
@ -254,94 +254,99 @@ table[role="table"] th[scope="col"] {
/* --- 7.2 Graphiques --- */
.stPlotlyChart text {
fill: var(--plot-text) !important;
fill: var(--plot-text) !important;
}
.stPlotlyChart text {
fill: black !important;
text-shadow: none !important;
font-weight: bold !important;
font-size: 14px !important;
font-family: Verdana, sans-serif !important;
fill: black !important;
text-shadow: none !important;
font-weight: bold !important;
font-size: 14px !important;
font-family: Verdana, sans-serif !important;
}
/* Cache complètement la section d'actions Vega */
.vega-actions {
display: none !important;
display: none !important;
}
/* Et aussi le <details> parent, s'il faut tout masquer */
details[title="Click to view actions"] {
display: none !important;
display: none !important;
}
/* --- 7.3 Détails et paragraphes --- */
details {
border: 1px solid #ccc;
border-radius: 6px;
padding: 0.5em;
margin-bottom: 0.5em;
background-color: var(--background-color);
border-color: var(--details-border) !important;
border: 1px solid #ccc;
border-radius: 6px;
padding: 0.5em;
margin-bottom: 0.5em;
background-color: var(--background-color);
border-color: var(--details-border) !important;
}
section:not([data-testid="stSidebar"])
div:not[data-testid="stElementContainer"]
p:not(#Authentification):not(#Theme) {
color: var(--paragraph-color) !important;
div:not[data-testid="stElementContainer"]
p:not(#Authentification):not(#Theme) {
color: var(--paragraph-color) !important;
}
section:not([data-testid="stSidebar"]) hr {
background-color: var(--hr-color) !important;
background-color: var(--hr-color) !important;
}
/* --- 7.4 Conteneurs de commentaires et tickets --- */
.conteneur_commentaire,
.conteneur_ticket {
background: var(--background-color);
padding: 1em;
border-radius: 8px;
margin-bottom: 1em;
border: 1px solid #ccc;
background: var(--background-color);
padding: 1em;
border-radius: 8px;
margin-bottom: 1em;
border: 1px solid #ccc;
}
.commentaire_auteur,
.ticket_auteur {
color: var(--text-color) !important;
margin: 0;
color: var(--text-color) !important;
margin: 0;
}
.commentaire_contenu,
.ticket_contenu {
color: var(--text-color) !important;
margin: 0.5rem 0 0;
color: var(--text-color) !important;
margin: 0.5rem 0 0;
}
/* --- 7.5 Blocs mathématiques --- */
.math-block {
display: block;
text-align: center;
margin: 1em 0;
border: 1px solid var(--math-block-border);
border-radius: 10px;
background: var(--math-block-bg);
font-size: x-large;
color: var(--text-color);
display: block;
text-align: center;
margin: 1em 0;
border: 1px solid var(--math-block-border);
border-radius: 10px;
background: var(--math-block-bg);
font-size: x-large;
color: var(--text-color);
}
.math-block math {
display: inline-block;
display: inline-block;
}
/* ==========================================
8. Éléments spécifiques
========================================== */
div.stElementContainer.element-container.st-key-nom_utilisateur {
display: none !important;
display: none !important;
}
.st-key-telecharger_fiche_pdf {
margin-left: auto;
margin-right: auto;
margin-top: 1rem;
margin-left: auto;
margin-right: auto;
margin-top: 1rem;
}
.details_introduction {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}

29
batch_ia/__init__.py Normal file
View File

@ -0,0 +1,29 @@
# batch_ia/__init__.py
# config.py
from .utils.config import TEMPLATE_PATH, load_config, session_uuid, TEMP_SECTIONS
# files.py
from .utils.files import write_report
# graphs.py
from .utils.graphs import (
parse_graphs,
extract_data_from_graph,
calculate_vulnerabilities
)
# sections.py
from .utils.sections import generate_report
__all__ = [
"TEMPLATE_PATH",
"session_uuid",
"TEMP_SECTIONS",
"load_config",
"write_report",
"parse_graphs",
"extract_data_from_graph",
"calculate_vulnerabilities",
"generate_report",
]

73
batch_ia/analyse_ia.py Normal file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour générer un rapport factorisé des vulnérabilités critiques
suivant la structure définie dans Remarques.md.
"""
import sys
from pathlib import Path
import streamlit as st
from utils.config import (
TEMP_SECTIONS,
TEMPLATE_PATH, session_uuid,
load_config
)
from utils.files import (
write_report
)
from utils.graphs import (
parse_graphs,
extract_data_from_graph,
calculate_vulnerabilities
)
from utils.sections import (
generate_report
)
from utils.sections_utils import (
nettoyer_texte_fr
)
from utils.ia import (
ingest_document,
ia_analyse,
supprimer_fichiers,
generer_rapport_final
)
def main(dot_path, output_path):
"""Fonction principale du script."""
# Charger la configuration
config = load_config()
# Analyser les graphes
graph, ref_graph = parse_graphs(dot_path)
# Extraire les données
data = extract_data_from_graph(graph, ref_graph)
# Calculer les vulnérabilités
results = calculate_vulnerabilities(data, config)
if "step" not in st.session_state:
st.session_state["step"] = 1
# Générer le rapport
report, file_names = generate_report(data, results, config)
# Écrire le rapport
write_report(report, TEMPLATE_PATH)
ingest_document(TEMPLATE_PATH)
# Générer l'analyse par l'IA du rapport complet
analyse_finale = nettoyer_texte_fr(ia_analyse(file_names))
analyse_fichier = TEMP_SECTIONS / TEMPLATE_PATH.name.replace(".md", " - analyse.md")
write_report(analyse_finale, analyse_fichier)
if generer_rapport_final(TEMPLATE_PATH, analyse_fichier, output_path):
supprimer_fichiers(session_uuid)
if __name__ == "__main__":
dot_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
main(dot_path, output_path)

View File

@ -0,0 +1,31 @@
[Unit]
Description=Service batch IA pour utilisateur fabnum
After=network.target
[Service]
Type=simple
User=fabnum
WorkingDirectory=/home/fabnum/fabnum-dev/batch_ia
Environment=PYTHONPATH=/home/fabnum/fabnum-dev
ExecStart=/home/fabnum/fabnum-dev/venv/bin/python /home/fabnum/fabnum-dev/batch_ia/batch_runner.py
Restart=always
Nice=10
CPUSchedulingPolicy=batch
# Limites de ressources
CPUQuota=87.5% # ~14 cores sur 16
MemoryMax=12G # RAM maximale autorisée
TasksMax=1 # maximum 1 subprocess/thread simultané
# Sécurité renforcée
ProtectSystem=full
ReadWritePaths=/home/fabnum/fabnum-dev/batch_ia
# Journal propre
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
# semanage fcontext -a -t svirt_sandbox_file_t "/home/fabnum/fabnum-dev/batch_ia(/.*)?"

32
batch_ia/batch_runner.py Normal file
View File

@ -0,0 +1,32 @@
import time
import subprocess
from batch_utils import charger_status, sauvegarder_status, JOBS_DIR
while True:
status = charger_status()
jobs = [(login, data) for login, data in status.items() if data["status"] == "en attente"]
jobs = sorted(jobs, key=lambda x: x[1].get("timestamp", 0))
for i, (login, data) in enumerate(jobs):
status[login]["position"] = i
sauvegarder_status(status)
if jobs:
login, _ = jobs[0]
dot_file = JOBS_DIR / f"{login}.dot"
result_file = JOBS_DIR / f"{login}.zip"
status[login]["status"] = "en cours"
sauvegarder_status(status)
try:
subprocess.run(["python3", "analyse_ia.py", str(dot_file), str(result_file)], check=True)
status[login]["status"] = "terminé"
except Exception as e:
status[login]["status"] = "échoué"
status[login]["error"] = str(e)
sauvegarder_status(status)
time.sleep(60)

64
batch_ia/batch_utils.py Normal file
View File

@ -0,0 +1,64 @@
import json
import time
from pathlib import Path
from networkx.drawing.nx_agraph import write_dot
import streamlit as st
from utils.translations import _
BATCH_DIR = Path(__file__).resolve().parent
JOBS_DIR = BATCH_DIR / "jobs"
STATUS_FILE = BATCH_DIR / "status.json"
ANALYSE = " - analyse.md"
RAPPORT = " - rapport.md"
def charger_status():
if STATUS_FILE.exists():
return json.loads(STATUS_FILE.read_text())
return {}
def sauvegarder_status(data):
STATUS_FILE.write_text(json.dumps(data, indent=2))
def statut_utilisateur(login):
status = charger_status()
entry = status.get(login)
if not entry:
return {"statut": None, "position": None, "telechargement": None,
"message": f"{str(_('batch.no_task'))}."}
if entry["status"] == "en attente":
return {"statut": "en attente", "position": entry.get("position"),
"telechargement": None,
"message": f"{str(_('batch.in_queue'))} (position {entry.get('position', '?')})."}
if entry["status"] == "en cours":
if "step" not in st.session_state:
st.session_state["step"] = 1
return {"statut": "en cours", "position": 0,
"telechargement": None, "message": f"{str(_('batch.in_progress'))} ({str(_('batch.step'))} {st.session_state["step"]}/5)."}
if entry["status"] == "terminé":
result_file = JOBS_DIR / f"{login}.zip"
if result_file.exists():
return {"statut": "terminé", "position": None,
"telechargement": result_file.read_bytes(),
"message": f"{str(_('batch.complete'))}."}
if entry["status"] == "échoué":
return {"statut": "échoué", "position": None, "telechargement": None,
"message": f"{str(_('batch.failure'))} : {entry.get('error', {str(_('batch.unknown_error'))})}"}
def soumettre_batch(login, G):
if statut_utilisateur(login)["statut"]:
raise RuntimeError("Un batch est déjà en cours.")
write_dot(G, JOBS_DIR / f"{login}.dot")
status = charger_status()
status[login] = {"status": "en attente", "timestamp": time.time()}
sauvegarder_status(status)
def nettoyage_post_telechargement(login):
(JOBS_DIR / f"{login}.dot").unlink(missing_ok=True)
(JOBS_DIR / f"{login}.zip").unlink(missing_ok=True)
status = charger_status()
status.pop(login, None)
sauvegarder_status(status)

100
batch_ia/nettoyer_pgpt.py Normal file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Script de nettoyage pour PrivateGPT
Ce script permet de lister et supprimer les documents ingérés dans PrivateGPT.
Options:
- Lister tous les documents
- Supprimer des documents par préfixe (ex: "temp_section_")
- Supprimer des documents par motif
- Supprimer tous les documents
"""
import json
import re
import requests
import time
from typing import List, Dict, Any, Optional
# Configuration de l'API PrivateGPT
PGPT_URL = "http://127.0.0.1:8001"
API_URL = f"{PGPT_URL}/v1"
def list_documents() -> List[Dict[str, Any]]:
"""Liste tous les documents ingérés et renvoie la liste des métadonnées"""
try:
# Récupérer la liste des documents
response = requests.get(f"{API_URL}/ingest/list")
response.raise_for_status()
data = response.json()
# Format de réponse OpenAI
if "data" in data:
documents = data.get("data", [])
# Format alternatif
else:
documents = data.get("documents", [])
# Construire une liste normalisée des documents
normalized_docs = []
for doc in documents:
doc_id = doc.get("doc_id") or doc.get("id")
metadata = doc.get("doc_metadata", {})
filename = metadata.get("file_name") or metadata.get("filename", "Inconnu")
normalized_docs.append({
"id": doc_id,
"filename": filename,
"metadata": metadata
})
return normalized_docs
except Exception as e:
print(f"❌ Erreur lors de la récupération des documents: {e}")
return []
def delete_document(doc_id: str) -> bool:
"""Supprime un document par son ID"""
try:
response = requests.delete(f"{API_URL}/ingest/{doc_id}")
if response.status_code == 200:
return True
else:
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
return False
except Exception as e:
print(f"❌ Erreur lors de la suppression de l'ID {doc_id}: {e}")
return False
def delete_documents_by_criteria(pattern) -> int:
"""
Supprime des documents selon différents critères
Retourne le nombre de documents supprimés
"""
documents = list_documents()
if not documents or not pattern:
return 0
# Comptage des suppressions réussies
success_count = 0
# Filtrer les documents à supprimer
docs_to_delete = []
try:
regex = re.compile(pattern)
docs_to_delete = [doc for doc in documents if regex.search(doc["filename"])]
except re.error as e:
return 0
# Supprimer les documents
for doc in docs_to_delete:
delete_document(doc["id"])
# Petite pause pour éviter de surcharger l'API
time.sleep(0.1)
return success_count

1
batch_ia/status.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@

93
batch_ia/utils/config.py Normal file
View File

@ -0,0 +1,93 @@
import os
import yaml
from pathlib import Path
import uuid
def init_uuid():
if not TEMP_SECTIONS.exists():
TEMP_SECTIONS.mkdir(parents=True)
session_uuid = str(uuid.uuid4())[:8] # Utiliser les 8 premiers caractères pour plus de concision
print(f"🔑 UUID de session généré: {session_uuid}")
return session_uuid
BASE_DIR = Path(__file__).resolve().parent.parent
CORPUS_DIR = BASE_DIR.parent / "Corpus"
THRESHOLDS_PATH = BASE_DIR.parent / "assets" / "config.yaml"
REFERENCE_GRAPH_PATH = BASE_DIR.parent / "schema.txt"
GRAPH_PATH = BASE_DIR.parent / "graphe.dot"
TEMP_SECTIONS = BASE_DIR / "temp_sections"
session_uuid = init_uuid()
TEMPLATE_PATH = TEMP_SECTIONS / f"rapport_final - {session_uuid}.md"
PGPT_URL = "http://127.0.0.1:8001"
API_URL = f"{PGPT_URL}/v1"
PROMPT_METHODOLOGIE = """
Le rapport à examiner a été établi à partir de la méthodologie suivante.
Le dispositif dévaluation des risques proposé repose sur quatre indices clairement définis, chacun analysant un aspect spécifique des risques dans la chaîne dapprovisionnement numérique. Lindice IHH mesure la concentration géographique ou industrielle, permettant dévaluer la dépendance vis-à-vis de certains acteurs ou régions. Lindice ISG indique la stabilité géopolitique des pays impliqués dans la chaîne de production, en intégrant des critères politiques, sociaux et climatiques. Lindice ICS quantifie la facilité ou la difficulté à remplacer ou substituer un élément spécifique dans la chaîne, évaluant ainsi les risques liés à la dépendance technologique et économique. Enfin, lindice IVC examine la pression concurrentielle sur les ressources utilisées par le numérique, révélant ainsi le risque potentiel que ces ressources soient détournées vers dautres secteurs industriels.
Ces indices se combinent judicieusement par paires pour une évaluation approfondie et pertinente des risques. La combinaison IHH-ISG permet dassocier la gravité d'un impact potentiel (IHH) à la probabilité de survenance dun événement perturbateur (ISG), créant ainsi une matrice de vulnérabilité combinée utile pour identifier rapidement les points critiques dans la chaîne de production. La combinaison ICS-IVC fonctionne selon la même logique, mais se concentre spécifiquement sur les ressources minérales : lICS indique la gravité potentielle d'une rupture d'approvisionnement due à une faible substituabilité, tandis que lIVC évalue la probabilité que les ressources soient captées par d'autres secteurs industriels concurrents. Ces combinaisons permettent dobtenir une analyse précise et opérationnelle du niveau de risque global.
Les avantages de cette méthodologie résident dans son approche à la fois systématique et granulaire, adaptée à l'échelle décisionnelle d'un COMEX. Elle permet didentifier avec précision les vulnérabilités majeures et leurs origines spécifiques, facilitant ainsi la prise de décision stratégique éclairée et proactive. En combinant des facteurs géopolitiques, industriels, technologiques et concurrentiels, ces indices offrent un suivi efficace de la chaîne de fabrication numérique, garantissant ainsi une gestion optimale des risques et la continuité opérationnelle à long terme.
"""
DICTIONNAIRE_CRITICITES = {
"IHH": {"vert": "Faible", "orange": "Modérée", "rouge": "Élevée"},
"ISG": {"vert": "Stable", "orange": "Intermédiaire", "rouge": "Instable"},
"ICS": {"vert": "Facile", "orange": "Moyenne", "rouge": "Difficile"},
"IVC": {"vert": "Faible", "orange": "Modérée", "rouge": "Forte"}
}
POIDS_COULEURS = {
"Vert": 1,
"Orange": 2,
"Rouge": 3
}
def load_config(thresholds_path=THRESHOLDS_PATH):
"""Charge la configuration depuis les fichiers YAML."""
config = {}
# Charger les seuils
if os.path.exists(thresholds_path):
with open(thresholds_path, 'r', encoding='utf-8') as f:
thresholds = yaml.safe_load(f)
config['thresholds'] = thresholds.get('seuils', {})
return config
def determine_threshold_color(value, index_type, thresholds):
"""
Détermine la couleur du seuil en fonction du type d'indice et de sa valeur.
Utilise les seuils de config.yaml si disponibles.
"""
# Récupérer les seuils pour cet indice
if index_type in thresholds:
index_thresholds = thresholds[index_type]
# Déterminer la couleur
if "vert" in index_thresholds and "max" in index_thresholds["vert"] and \
index_thresholds["vert"]["max"] is not None and value < index_thresholds["vert"]["max"]:
suffix = get_suffix_for_index(index_type, "vert")
return "Vert", suffix
elif "orange" in index_thresholds and "min" in index_thresholds["orange"] and "max" in index_thresholds["orange"] and \
index_thresholds["orange"]["min"] is not None and index_thresholds["orange"]["max"] is not None and \
index_thresholds["orange"]["min"] <= value < index_thresholds["orange"]["max"]:
suffix = get_suffix_for_index(index_type, "orange")
return "Orange", suffix
elif "rouge" in index_thresholds and "min" in index_thresholds["rouge"] and \
index_thresholds["rouge"]["min"] is not None and value >= index_thresholds["rouge"]["min"]:
suffix = get_suffix_for_index(index_type, "rouge")
return "Rouge", suffix
return "Non déterminé", ""
def get_suffix_for_index(index_type, color):
"""Retourne le suffixe approprié pour chaque indice et couleur."""
suffixes = DICTIONNAIRE_CRITICITES
if index_type in suffixes and color in suffixes[index_type]:
return suffixes[index_type][color]
return ""
def get_weight_for_color(color):
"""Retourne le poids correspondant à une couleur."""
weights = POIDS_COULEURS
return weights.get(color, 0)

133
batch_ia/utils/files.py Normal file
View File

@ -0,0 +1,133 @@
import os
import re
from .config import (
CORPUS_DIR
)
def strip_prefix(name):
"""Supprime le préfixe numérique éventuel d'un nom de fichier ou de dossier."""
return re.sub(r'^\d+[-_ ]*', '', name).lower()
def find_prefixed_directory(pattern, base_path=None):
"""
Recherche un sous-répertoire dont le nom (sans préfixe) correspond au pattern.
Args:
pattern: Nom du répertoire sans préfixe
base_path: Répertoire de base chercher
Returns:
Le chemin relatif du répertoire trouvé (avec préfixe) ou None
"""
if base_path:
search_path = os.path.join(CORPUS_DIR, base_path)
else:
search_path = CORPUS_DIR
if not os.path.exists(search_path):
# print(f"Chemin inexistant: {search_path}")
return None
for d in os.listdir(search_path):
dir_path = os.path.join(search_path, d)
if os.path.isdir(dir_path) and strip_prefix(d) == pattern.lower():
return os.path.relpath(dir_path, CORPUS_DIR)
# print(f"Aucun répertoire correspondant à: '{pattern}' trouvé dans {search_path}")
return None
def find_corpus_file(pattern, base_path=None):
"""
Recherche récursive dans le corpus d'un fichier en ignorant les préfixes numériques dans les dossiers et fichiers.
Args:
pattern: Chemin relatif type "sous-dossier/nom-fichier"
base_path: Dossier de base à partir duquel chercher
Returns:
Chemin relatif du fichier trouvé ou None
"""
if base_path:
search_path = os.path.join(CORPUS_DIR, base_path)
else:
search_path = CORPUS_DIR
# # print(f"Recherche de: '{pattern}' dans {search_path}")
if not os.path.exists(search_path):
# print(pattern)
# print(base_path)
# print(f"Chemin inexistant: {search_path}")
return None
if '/' not in pattern:
# Recherche directe d'un fichier
for file in os.listdir(search_path):
if not file.endswith('.md'):
continue
if strip_prefix(os.path.splitext(file)[0]) == pattern.lower():
rel_path = os.path.relpath(os.path.join(search_path, file), CORPUS_DIR)
# # print(f"Fichier trouvé: {rel_path}")
return rel_path
else:
# Séparation du chemin en dossier/fichier
first, rest = pattern.split('/', 1)
matched_dir = find_prefixed_directory(first, base_path)
if matched_dir:
return find_corpus_file(rest, matched_dir)
# print(f"Aucun fichier correspondant à: '{pattern}' trouvé dans {base_path}.")
return None
def read_corpus_file(file_path, remove_first_title=False, shift_titles=0):
"""
Lit un fichier du corpus et applique les transformations demandées.
Args:
file_path: Chemin relatif du fichier dans le corpus
remove_first_title: Si True, supprime la première ligne de titre
shift_titles: Nombre de niveaux à ajouter aux titres
Returns:
Le contenu du fichier avec les transformations appliquées
"""
full_path = os.path.join(CORPUS_DIR, file_path)
if not os.path.exists(full_path):
# print(f"Fichier non trouvé: {full_path}")
return f"Fichier non trouvé: {file_path}"
# # print(f"Lecture du fichier: {full_path}")
with open(full_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Supprimer la première ligne si c'est un titre et si demandé
if remove_first_title and lines and lines[0].startswith('#'):
# # print(f"Suppression du titre: {lines[0].strip()}")
lines = lines[1:]
# Décaler les niveaux de titre si demandé
if shift_titles > 0:
for i in range(len(lines)):
if lines[i].startswith('#'):
lines[i] = '#' * shift_titles + lines[i]
# Nettoyer les retours à la ligne superflus
content = ''.join(lines)
# Supprimer les retours à la ligne en fin de contenu
content = content.rstrip('\n') + '\n'
return content
def write_report(report, fichier):
"""Écrit le rapport généré dans le fichier spécifié."""
report = re.sub(r'<!----.*?-->', '', report)
report = re.sub(r'\n\n\n+', '\n\n', report)
with open(fichier, 'w', encoding='utf-8') as f:
f.write(report)
# print(f"Rapport généré avec succès: {TEMPLATE_PATH}")

591
batch_ia/utils/graphs.py Normal file
View File

@ -0,0 +1,591 @@
import os
import sys
from networkx.drawing.nx_agraph import read_dot
from .config import (
REFERENCE_GRAPH_PATH,
determine_threshold_color, get_weight_for_color
)
def parse_graphs(graphe_path):
"""
Charge et analyse les graphes DOT (analyse et référence).
"""
print(graphe_path)
# Charger le graphe à analyser
if not os.path.exists(graphe_path):
print(f"Fichier de graphe à analyser introuvable: {graphe_path}")
sys.exit(1)
# Charger le graphe de référence
reference_path = REFERENCE_GRAPH_PATH
if not os.path.exists(reference_path):
print(f"Fichier de graphe de référence introuvable: {reference_path}")
sys.exit(1)
try:
# Charger les graphes avec NetworkX
graph = read_dot(graphe_path)
ref_graph = read_dot(reference_path)
# Convertir les attributs en types appropriés pour les deux graphes
for g in [graph, ref_graph]:
for node, attrs in g.nodes(data=True):
for key, value in list(attrs.items()):
# Convertir les valeurs numériques
if key in ['niveau', 'ihh_acteurs', 'ihh_pays', 'isg', 'ivc']:
try:
if key in ['isg', 'ivc', 'ihh_acteurs', 'ihh_pays', 'niveau']:
attrs[key] = int(value.strip('"'))
else:
attrs[key] = float(value.strip('"'))
except (ValueError, TypeError):
# Garder la valeur originale si la conversion échoue
pass
elif key == 'label':
# Nettoyer les guillemets des étiquettes
attrs[key] = value.strip('"')
# Convertir les attributs des arêtes
for u, v, attrs in g.edges(data=True):
for key, value in list(attrs.items()):
if key in ['ics', 'cout', 'delai', 'technique']:
try:
attrs[key] = float(value.strip('"'))
except (ValueError, TypeError):
pass
elif key == 'label' and '%' in value:
# Extraire le pourcentage
try:
percentage = value.strip('"').replace('%', '')
attrs['percentage'] = float(percentage)
except (ValueError, TypeError):
pass
return graph, ref_graph
except Exception as e:
print(f"Erreur lors de l'analyse des graphes: {str(e)}")
sys.exit(1)
def extract_data_from_graph(graph, ref_graph):
"""
Extrait toutes les données pertinentes des graphes DOT.
"""
data = {
"products": {}, # Produits finaux (N0)
"components": {}, # Composants (N1)
"minerals": {}, # Minerais (N2)
"operations": {}, # Opérations (N10)
"countries": {}, # Pays (N11)
"geo_countries": {}, # Pays géographiques (N99)
"actors": {} # Acteurs (N12)
}
# Extraire tous les pays géographiques du graphe de référence
for node, attrs in ref_graph.nodes(data=True):
if attrs.get('niveau') == 99:
country_name = attrs.get('label', node)
isg_value = attrs.get('isg', 0)
data["geo_countries"][country_name] = {
"id": node,
"isg": isg_value
}
# Extraire les nœuds du graphe à analyser
for node, attrs in graph.nodes(data=True):
level = attrs.get('niveau', -1)
label = attrs.get('label', node)
if level == 0 or level == 1000: # Produit final
data["products"][node] = {
"label": label,
"components": [],
"assembly": None,
"level": level
}
elif level == 1 or level == 1001: # Composant
data["components"][node] = {
"label": label,
"minerals": [],
"manufacturing": None
}
elif level == 2: # Minerai
data["minerals"][node] = {
"label": label,
"ivc": attrs.get('ivc', 0),
"extraction": None,
"treatment": None,
"ics_values": {}
}
elif level == 10 or level == 1010: # Opération
op_type = label.lower()
data["operations"][node] = {
"label": label,
"type": op_type,
"ihh_acteurs": attrs.get('ihh_acteurs', 0),
"ihh_pays": attrs.get('ihh_pays', 0),
"countries": {}
}
elif level == 11 or level == 1011: # Pays
data["countries"][node] = {
"label": label,
"actors": {},
"geo_country": None,
"market_share": 0
}
elif level == 12 or level == 1012: # Acteur
data["actors"][node] = {
"label": label,
"country": None,
"market_share": 0
}
# Extraire les relations et attributs des arêtes
for source, target, edge_attrs in graph.edges(data=True):
if source not in graph.nodes or target not in graph.nodes:
continue
source_level = graph.nodes[source].get('niveau', -1)
target_level = graph.nodes[target].get('niveau', -1)
# Extraire part de marché
market_share = 0
if 'percentage' in edge_attrs:
market_share = edge_attrs['percentage']
elif 'label' in edge_attrs and '%' in edge_attrs['label']:
try:
market_share = float(edge_attrs['label'].strip('"').replace('%', ''))
except (ValueError, TypeError):
pass
# Relations produit → composant
if (source_level == 0 and target_level == 1) or (source_level == 1000 and target_level == 1001):
if target not in data["products"][source]["components"]:
data["products"][source]["components"].append(target)
# Relations produit → opération (assemblage)
elif (source_level == 0 and target_level == 10) or (source_level == 1000 and target_level == 1010):
if graph.nodes[target].get('label', '').lower() == 'assemblage':
data["products"][source]["assembly"] = target
# Relations composant → minerai avec ICS
elif (source_level == 1 or source_level == 1001) and target_level == 2:
if target not in data["components"][source]["minerals"]:
data["components"][source]["minerals"].append(target)
# Stocker l'ICS s'il est présent
if 'ics' in edge_attrs:
ics_value = edge_attrs['ics']
data["minerals"][target]["ics_values"][source] = ics_value
# Relations composant → opération (fabrication)
elif (source_level == 1 or source_level == 1001) and target_level == 10:
if graph.nodes[target].get('label', '').lower() == 'fabrication':
data["components"][source]["manufacturing"] = target
# Relations minerai → opération (extraction/traitement)
elif source_level == 2 and target_level == 10:
op_label = graph.nodes[target].get('label', '').lower()
if 'extraction' in op_label:
data["minerals"][source]["extraction"] = target
elif 'traitement' in op_label:
data["minerals"][source]["treatment"] = target
# Relations opération → pays avec part de marché
elif (source_level == 10 and target_level == 11) or (source_level == 1010 and target_level == 1011):
data["operations"][source]["countries"][target] = market_share
data["countries"][target]["market_share"] = market_share
# Relations pays → acteur avec part de marché
elif (source_level == 11 and target_level == 12) or (source_level == 1011 and target_level == 1012):
data["countries"][source]["actors"][target] = market_share
data["actors"][target]["market_share"] = market_share
data["actors"][target]["country"] = source
# Relations pays → pays géographique
elif (source_level == 11 or source_level == 1011) and target_level == 99:
country_name = graph.nodes[target].get('label', '')
data["countries"][source]["geo_country"] = country_name
# Compléter les opérations manquantes pour les produits et composants
# en les récupérant du graphe de référence si elles existent
# Pour les produits finaux (N0)
for product_id, product_data in data["products"].items():
if product_data["assembly"] is None:
# Chercher l'opération d'assemblage dans le graphe de référence
for source, target, edge_attrs in ref_graph.edges(data=True):
if (source == product_id and
((ref_graph.nodes[source].get('niveau') == 0 and
ref_graph.nodes[target].get('niveau') == 10) or
(ref_graph.nodes[source].get('niveau') == 1000 and
ref_graph.nodes[target].get('niveau') == 1010)) and
ref_graph.nodes[target].get('label', '').lower() == 'assemblage'):
# L'opération existe dans le graphe de référence
assembly_id = target
product_data["assembly"] = assembly_id
# Ajouter l'opération si elle n'existe pas déjà
if assembly_id not in data["operations"]:
data["operations"][assembly_id] = {
"label": ref_graph.nodes[assembly_id].get('label', assembly_id),
"type": "assemblage",
"ihh_acteurs": ref_graph.nodes[assembly_id].get('ihh_acteurs', 0),
"ihh_pays": ref_graph.nodes[assembly_id].get('ihh_pays', 0),
"countries": {}
}
# Extraire les relations de l'opération vers les pays
for op_source, op_target, op_edge_attrs in ref_graph.edges(data=True):
if (op_source == assembly_id and
(ref_graph.nodes[op_target].get('niveau') == 11 or ref_graph.nodes[op_target].get('niveau') == 1011)):
country_id = op_target
# Extraire part de marché
market_share = 0
if 'percentage' in op_edge_attrs:
market_share = op_edge_attrs['percentage']
elif 'label' in op_edge_attrs and '%' in op_edge_attrs['label']:
try:
market_share = float(op_edge_attrs['label'].strip('"').replace('%', ''))
except (ValueError, TypeError):
pass
# Ajouter le pays à l'opération
data["operations"][assembly_id]["countries"][country_id] = market_share
# Ajouter le pays s'il n'existe pas déjà
if country_id not in data["countries"]:
data["countries"][country_id] = {
"label": ref_graph.nodes[country_id].get('label', country_id),
"actors": {},
"geo_country": None,
"market_share": market_share
}
else:
data["countries"][country_id]["market_share"] = market_share
# Extraire les relations du pays vers les acteurs
for country_source, country_target, country_edge_attrs in ref_graph.edges(data=True):
if (country_source == country_id and
(ref_graph.nodes[country_target].get('niveau') == 12 or ref_graph.nodes[country_target].get('niveau') == 1012)):
actor_id = country_target
# Extraire part de marché
actor_market_share = 0
if 'percentage' in country_edge_attrs:
actor_market_share = country_edge_attrs['percentage']
elif 'label' in country_edge_attrs and '%' in country_edge_attrs['label']:
try:
actor_market_share = float(country_edge_attrs['label'].strip('"').replace('%', ''))
except (ValueError, TypeError):
pass
# Ajouter l'acteur au pays
data["countries"][country_id]["actors"][actor_id] = actor_market_share
# Ajouter l'acteur s'il n'existe pas déjà
if actor_id not in data["actors"]:
data["actors"][actor_id] = {
"label": ref_graph.nodes[actor_id].get('label', actor_id),
"country": country_id,
"market_share": actor_market_share
}
else:
data["actors"][actor_id]["market_share"] = actor_market_share
data["actors"][actor_id]["country"] = country_id
# Extraire la relation du pays vers le pays géographique
for geo_source, geo_target, geo_edge_attrs in ref_graph.edges(data=True):
if (geo_source == country_id and
ref_graph.nodes[geo_target].get('niveau') == 99):
geo_country_name = ref_graph.nodes[geo_target].get('label', '')
data["countries"][country_id]["geo_country"] = geo_country_name
break # Une seule opération d'assemblage par produit
# Pour les composants (N1)
for component_id, component_data in data["components"].items():
if component_data["manufacturing"] is None:
# Chercher l'opération de fabrication dans le graphe de référence
for source, target, edge_attrs in ref_graph.edges(data=True):
if (source == component_id and
((ref_graph.nodes[source].get('niveau') == 1 and
ref_graph.nodes[target].get('niveau') == 10) or
(ref_graph.nodes[source].get('niveau') == 1001 and
ref_graph.nodes[target].get('niveau') == 1010)) and
ref_graph.nodes[target].get('label', '').lower() == 'fabrication'):
# L'opération existe dans le graphe de référence
manufacturing_id = target
component_data["manufacturing"] = manufacturing_id
# Ajouter l'opération si elle n'existe pas déjà
if manufacturing_id not in data["operations"]:
data["operations"][manufacturing_id] = {
"label": ref_graph.nodes[manufacturing_id].get('label', manufacturing_id),
"type": "fabrication",
"ihh_acteurs": ref_graph.nodes[manufacturing_id].get('ihh_acteurs', 0),
"ihh_pays": ref_graph.nodes[manufacturing_id].get('ihh_pays', 0),
"countries": {}
}
# Extraire les relations de l'opération vers les pays
for op_source, op_target, op_edge_attrs in ref_graph.edges(data=True):
if (op_source == manufacturing_id and
(ref_graph.nodes[op_target].get('niveau') == 11 or ref_graph.nodes[op_target].get('niveau') == 1011)):
country_id = op_target
# Extraire part de marché
market_share = 0
if 'percentage' in op_edge_attrs:
market_share = op_edge_attrs['percentage']
elif 'label' in op_edge_attrs and '%' in op_edge_attrs['label']:
try:
market_share = float(op_edge_attrs['label'].strip('"').replace('%', ''))
except (ValueError, TypeError):
pass
# Ajouter le pays à l'opération
data["operations"][manufacturing_id]["countries"][country_id] = market_share
# Ajouter le pays s'il n'existe pas déjà
if country_id not in data["countries"]:
data["countries"][country_id] = {
"label": ref_graph.nodes[country_id].get('label', country_id),
"actors": {},
"geo_country": None,
"market_share": market_share
}
else:
data["countries"][country_id]["market_share"] = market_share
# Extraire les relations du pays vers les acteurs
for country_source, country_target, country_edge_attrs in ref_graph.edges(data=True):
if (country_source == country_id and
(ref_graph.nodes[country_target].get('niveau') == 12 or ref_graph.nodes[country_target].get('niveau') == 1012)):
actor_id = country_target
# Extraire part de marché
actor_market_share = 0
if 'percentage' in country_edge_attrs:
actor_market_share = country_edge_attrs['percentage']
elif 'label' in country_edge_attrs and '%' in country_edge_attrs['label']:
try:
actor_market_share = float(country_edge_attrs['label'].strip('"').replace('%', ''))
except (ValueError, TypeError):
pass
# Ajouter l'acteur au pays
data["countries"][country_id]["actors"][actor_id] = actor_market_share
# Ajouter l'acteur s'il n'existe pas déjà
if actor_id not in data["actors"]:
data["actors"][actor_id] = {
"label": ref_graph.nodes[actor_id].get('label', actor_id),
"country": country_id,
"market_share": actor_market_share
}
else:
data["actors"][actor_id]["market_share"] = actor_market_share
data["actors"][actor_id]["country"] = country_id
# Extraire la relation du pays vers le pays géographique
for geo_source, geo_target, geo_edge_attrs in ref_graph.edges(data=True):
if (geo_source == country_id and
ref_graph.nodes[geo_target].get('niveau') == 99):
geo_country_name = ref_graph.nodes[geo_target].get('label', '')
data["countries"][country_id]["geo_country"] = geo_country_name
break # Une seule opération de fabrication par composant
return data
def calculate_vulnerabilities(data, config):
"""
Calcule les vulnérabilités combinées pour toutes les opérations et minerais.
"""
thresholds = config.get('thresholds', {})
results = {
"ihh_isg_combined": {}, # Pour chaque opération
"ics_ivc_combined": {}, # Pour chaque minerai
"chains": [] # Pour stocker tous les chemins possibles
}
# 1. Calculer ISG_combiné pour chaque opération
for op_id, operation in data["operations"].items():
isg_weighted_sum = 0
total_share = 0
# Parcourir chaque pays impliqué dans l'opération
for country_id, share in operation["countries"].items():
country = data["countries"][country_id]
geo_country = country.get("geo_country")
if geo_country and geo_country in data["geo_countries"]:
isg_value = data["geo_countries"][geo_country]["isg"]
isg_weighted_sum += isg_value * share
total_share += share
# Calculer la moyenne pondérée
isg_combined = 0
if total_share > 0:
isg_combined = isg_weighted_sum / total_share
# Déterminer couleurs et poids
ihh_value = operation["ihh_pays"]
ihh_color, ihh_suffix = determine_threshold_color(ihh_value, "IHH", thresholds)
isg_color, isg_suffix = determine_threshold_color(isg_combined, "ISG", thresholds)
# Calculer poids combiné
ihh_weight = get_weight_for_color(ihh_color)
isg_weight = get_weight_for_color(isg_color)
combined_weight = ihh_weight * isg_weight
# Déterminer vulnérabilité combinée
if combined_weight in [6, 9]:
vulnerability = "ÉLEVÉE à CRITIQUE"
elif combined_weight in [3, 4]:
vulnerability = "MOYENNE"
else: # 1, 2
vulnerability = "FAIBLE"
# Stocker résultats
results["ihh_isg_combined"][op_id] = {
"ihh_value": ihh_value,
"ihh_color": ihh_color,
"ihh_suffix": ihh_suffix,
"isg_combined": isg_combined,
"isg_color": isg_color,
"isg_suffix": isg_suffix,
"combined_weight": combined_weight,
"vulnerability": vulnerability
}
# 2. Calculer ICS_moyen pour chaque minerai
for mineral_id, mineral in data["minerals"].items():
ics_values = list(mineral["ics_values"].values())
ics_average = 0
if ics_values:
ics_average = sum(ics_values) / len(ics_values)
ivc_value = mineral.get("ivc", 0)
# Déterminer couleurs et poids
ics_color, ics_suffix = determine_threshold_color(ics_average, "ICS", thresholds)
ivc_color, ivc_suffix = determine_threshold_color(ivc_value, "IVC", thresholds)
# Calculer poids combiné
ics_weight = get_weight_for_color(ics_color)
ivc_weight = get_weight_for_color(ivc_color)
combined_weight = ics_weight * ivc_weight
# Déterminer vulnérabilité combinée
if combined_weight in [6, 9]:
vulnerability = "ÉLEVÉE à CRITIQUE"
elif combined_weight in [3, 4]:
vulnerability = "MOYENNE"
else: # 1, 2
vulnerability = "FAIBLE"
# Stocker résultats
results["ics_ivc_combined"][mineral_id] = {
"ics_average": ics_average,
"ics_color": ics_color,
"ics_suffix": ics_suffix,
"ivc_value": ivc_value,
"ivc_color": ivc_color,
"ivc_suffix": ivc_suffix,
"combined_weight": combined_weight,
"vulnerability": vulnerability
}
# 3. Identifier tous les chemins et leurs vulnérabilités
for product_id, product in data["products"].items():
for component_id in product["components"]:
component = data["components"][component_id]
for mineral_id in component["minerals"]:
mineral = data["minerals"][mineral_id]
# Collecter toutes les vulnérabilités dans ce chemin
path_vulnerabilities = []
# Assemblage (si présent)
assembly_id = product["assembly"]
if assembly_id and assembly_id in results["ihh_isg_combined"]:
path_vulnerabilities.append({
"type": "assemblage",
"vulnerability": results["ihh_isg_combined"][assembly_id]["vulnerability"],
"operation_id": assembly_id
})
# Fabrication (si présent)
manufacturing_id = component["manufacturing"]
if manufacturing_id and manufacturing_id in results["ihh_isg_combined"]:
path_vulnerabilities.append({
"type": "fabrication",
"vulnerability": results["ihh_isg_combined"][manufacturing_id]["vulnerability"],
"operation_id": manufacturing_id
})
# Minerai (ICS+IVC)
if mineral_id in results["ics_ivc_combined"]:
path_vulnerabilities.append({
"type": "minerai",
"vulnerability": results["ics_ivc_combined"][mineral_id]["vulnerability"],
"mineral_id": mineral_id
})
# Extraction (si présent)
extraction_id = mineral["extraction"]
if extraction_id and extraction_id in results["ihh_isg_combined"]:
path_vulnerabilities.append({
"type": "extraction",
"vulnerability": results["ihh_isg_combined"][extraction_id]["vulnerability"],
"operation_id": extraction_id
})
# Traitement (si présent)
treatment_id = mineral["treatment"]
if treatment_id and treatment_id in results["ihh_isg_combined"]:
path_vulnerabilities.append({
"type": "traitement",
"vulnerability": results["ihh_isg_combined"][treatment_id]["vulnerability"],
"operation_id": treatment_id
})
# Classifier le chemin
path_info = {
"product": product_id,
"component": component_id,
"mineral": mineral_id,
"vulnerabilities": path_vulnerabilities
}
# Déterminer le niveau de risque du chemin
critical_count = path_vulnerabilities.count({"vulnerability": "ÉLEVÉE à CRITIQUE"})
medium_count = path_vulnerabilities.count({"vulnerability": "MOYENNE"})
if any(v["vulnerability"] == "ÉLEVÉE à CRITIQUE" for v in path_vulnerabilities):
path_info["risk_level"] = "critique"
elif medium_count >= 3:
path_info["risk_level"] = "majeur"
elif any(v["vulnerability"] == "MOYENNE" for v in path_vulnerabilities):
path_info["risk_level"] = "moyen"
else:
path_info["risk_level"] = "faible"
results["chains"].append(path_info)
return results

286
batch_ia/utils/ia.py Normal file
View File

@ -0,0 +1,286 @@
import re
from pathlib import Path
import requests
import json
import time
import zipfile
import streamlit as st
from nettoyer_pgpt import (
delete_documents_by_criteria
)
from utils.config import (
TEMP_SECTIONS,
session_uuid,
API_URL, PROMPT_METHODOLOGIE
)
def ingest_document(file_path: Path) -> bool:
"""Ingère un document dans PrivateGPT"""
try:
with open(file_path, "rb") as f:
file_name = file_path.name
files = {"file": (file_name, f, "text/markdown")}
# Ajouter des métadonnées pour identifier facilement ce fichier d'entrée
metadata = {
"type": "input_file",
"session_id": session_uuid,
"document_type": "rapport_analyse_input"
}
response = requests.post(
f"{API_URL}/ingest/file",
files=files,
data={"metadata": json.dumps(metadata)} if "metadata" in requests.get(f"{API_URL}/ingest/file").text else None
)
response.raise_for_status()
print(f"✅ Document '{file_path}' ingéré avec succès sous le nom '{file_name}'")
return True
except FileNotFoundError:
print(f"❌ Fichier '{file_path}' introuvable")
return False
except requests.RequestException as e:
print(f"❌ Erreur lors de l'ingestion du document: {e}")
return False
def generate_text(input_file, full_prompt, system_message, temperature = "0.3", use_context = True):
"""Génère du texte avec l'API PrivateGPT"""
try:
# Définir les paramètres de la requête
payload = {
"messages": [
{"role": "system", "content": system_message},
{"role": "user", "content": full_prompt}
],
"use_context": use_context, # Active la recherche RAG dans les documents ingérés
"temperature": temperature, # Température réduite pour plus de cohérence
"stream": False
}
# Tenter d'ajouter un filtre de contexte (fonctionnalité expérimentale qui peut ne pas être supportée)
if input_file:
try:
# Vérifier si le filtre de contexte est supporté sans faire de requête supplémentaire
liste_des_fichiers = list(TEMP_SECTIONS.glob(f"*{session_uuid}*.md"))
filter_metadata = {
"document_name": [input_file.name] + [f.name for f in liste_des_fichiers]
}
payload["filter_metadata"] = filter_metadata
except Exception as e:
print(f" Remarque: Impossible d'appliquer le filtre de contexte: {e}")
# Envoyer la requête
response = requests.post(
f"{API_URL}/chat/completions",
json=payload,
headers={"accept": "application/json"}
)
response.raise_for_status()
# Extraire la réponse générée
result = response.json()
if "choices" in result and len(result["choices"]) > 0:
return result["choices"][0]["message"]["content"]
else:
print("⚠️ Format de réponse inattendu:", json.dumps(result, indent=2))
return None
except requests.RequestException as e:
print(f"❌ Erreur lors de la génération de texte: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Détails: {e.response.text}")
return None
def ia_analyse(file_names):
for file in file_names:
ingest_document(file)
time.sleep(5)
reponse = {}
for file in file_names:
produit_final = re.search(r"chemins critiques (.+)\.md$", file.name).group(1)
# Préparer le prompt avec le contexte précédent si disponible et demandé
full_prompt = f"""
Rédigez une synthèse du fichier {file.name} dédiée au produit final '{produit_final}'.
Cette synthèse, destinée spécifiquement au Directeur des Risques, membre du COMEX d'une grande entreprise utilisant ce produit, doit être claire et concise (environ 10 lignes).
En utilisant impérativement la méthodologie fournie, expliquez en termes simples mais précis, pourquoi et comment les vulnérabilités identifiées constituent un risque concret pour l'entreprise. Mentionnez clairement :
- Les composants spécifiques du produit '{produit_final}' concernés par ces vulnérabilités.
- Les minerais précis responsables de ces vulnérabilités et leur rôle dans limpact sur les composants.
- Les points critiques exacts identifiés dans la chaîne d'approvisionnement (par exemple : faible substituabilité, forte concentration géographique, instabilité géopolitique, concurrence élevée entre secteurs industriels).
- Identifier autant que faire se peut, les pays générant la forte concentration géographiques ou qui sont sujet à instabilité géopolitique, les secteurs en concurrence avec le numérique pour les minerais.
Respectez strictement les consignes suivantes :
- N'utilisez aucun acronyme ni valeur numérique ; uniquement leur équivalent textuel (ex : criticité de substituabilité, vulnérabilité élevée ou critique, etc.).
- N'incluez à ce stade aucune préconisation ni recommandation.
Votre texte doit être parfaitement adapté à une compréhension rapide par des dirigeants dentreprise.
"""
# Définir les paramètres de la requête
system_message = f"""
Vous êtes un assistant stratégique expert chargé de rédiger des synthèses destinées à des décideurs de très haut niveau (Directeurs des Risques, membres du COMEX, stratèges industriels). Vous analysez exclusivement les vulnérabilités systémiques affectant les produits numériques, à partir des données précises fournies dans le fichier {file.name}.
Votre analyse doit être rigoureuse, accessible, pertinente pour la prise de décision stratégique, et conforme à la méthodologie définie ci-dessous :
{PROMPT_METHODOLOGIE}
/no_think
"""
reponse[produit_final] = f"\n**{produit_final}**\n\n" + generate_text(file, full_prompt, system_message).split("</think>")[-1].strip()
# print(reponse[produit_final])
corps = "\n\n".join(reponse.values())
print("Corps")
st.session_state["step"] = 2
full_prompt = corps + "\n\n" + PROMPT_METHODOLOGIE
system_message = """
Vous êtes un expert en rédaction de rapports stratégiques destinés à un COMEX ou une Direction des Risques.
Votre mission est d'écrire une introduction professionnelle, claire et synthétique (maximum 7 lignes) à partir des éléments suivants :
1. Un corps danalyse décrivant les vulnérabilités identifiées pour un produit numérique.
2. La méthodologie détaillée utilisée pour cette analyse (fourni en deuxième partie).
Votre introduction doit :
- Présenter brièvement le sujet traité (vulnérabilités du produit final), quels sont les produits finaux et les minerais concernés.
- Annoncer clairement le contenu et l'objectif de l'analyse présentée dans le corps.
- Résumer succinctement les axes méthodologiques principaux (concentration géographique ou industrielle, stabilité géopolitique, criticité de substituabilité, concurrence intersectorielle des minerais).
- Être facilement compréhensible par des décideurs de haut niveau (pas d'acronymes, ni chiffres ; uniquement des formulations textuelles).
- Être fluide, agréable à lire, avec un ton sobre et professionnel.
Répondez uniquement avec l'introduction rédigée. Ne fournissez aucune autre explication complémentaire.
/no_think
"""
introduction = generate_text("", full_prompt, system_message).split("</think>")[-1].strip()
print("Introduction")
st.session_state["step"] = 3
full_prompt = corps + "\n\n" + PROMPT_METHODOLOGIE
system_message = """
Vous êtes un expert stratégique en gestion des risques liés à la chaîne de valeur numérique. Vous conseillez directement le COMEX et la Direction des Risques de grandes entreprises utilisatrices de produits numériques. Ces entreprises n'ont pour levier daction que le choix de leurs fournisseurs ou l'allongement de la durée de vie de leur matériel.
À partir des vulnérabilités identifiées dans la première partie du prompt (corps d'analyse) et en tenant compte du contexte et de la méthodologie décrite en deuxième partie, rédigez un texte clair, structuré en deux parties distinctes :
1. **Préconisations stratégiques :**
Proposez clairement des axes concrets pour limiter les risques identifiés dans lanalyse. Ces préconisations doivent impérativement être réalistes et directement actionnables par les dirigeants compte tenu de leurs leviers limités.
2. **Indicateurs de suivi :**
Identifiez précisément les indicateurs pertinents à suivre pour évaluer régulièrement lévolution de ces risques. Ces indicateurs doivent être inspirés directement des axes méthodologiques fournis (concentration géographique, stabilité géopolitique, substituabilité, concurrence intersectorielle) ou sappuyer sur des bonnes pratiques reconnues.
Votre rédaction doit être fluide, concise, très professionnelle, et directement accessible à un COMEX. Évitez strictement toute explication complémentaire ou ajout superflu. Ne proposez que le texte demandé.
"""
preconisations = generate_text("", full_prompt, system_message, "0.5").split("</think>")[-1].strip()
print("Préconisations")
st.session_state["step"] = 4
full_prompt = corps + "\n\n" + preconisations
system_message = """
Vous êtes un expert stratégique spécialisé dans les risques liés à la chaîne de valeur du numérique. Vous conseillez directement le COMEX et la Direction des Risques de grandes entreprises dépendantes du numérique, dont les leviers daction se limitent au choix des fournisseurs et à lallongement de la durée dutilisation du matériel.
À partir du résultat de l'analyse des vulnérabilités présenté en première partie du prompt (corps) et des préconisations stratégiques formulées en deuxième partie, rédigez une conclusion synthétique et percutante (environ 6 à 8 lignes maximum) afin de :
- Résumer clairement les principaux risques identifiés.
- Souligner brièvement les axes prioritaires proposés pour agir concrètement.
- Inviter de manière dynamique le COMEX à passer immédiatement à l'action.
Votre rédaction doit être fluide, professionnelle, claire et immédiatement exploitable par des dirigeants. Ne fournissez aucune explication supplémentaire. Ne répondez que par la conclusion demandée.
/no_think
"""
conclusion = generate_text("", full_prompt, system_message, "0.7").split("</think>")[-1].strip()
print("Conclusion")
st.session_state["step"] = 5
analyse = "# Rapport d'analyse\n\n" + \
"\n\n## Introduction\n\n" + \
introduction + \
"\n\n## Analyse des produits finaux\n\n" + \
corps + \
"\n\n## Préconisations\n\n" + \
preconisations + \
"\n\n## Conclusion\n\n" + \
conclusion + \
"\n\n## Méthodologie\n\n" + \
PROMPT_METHODOLOGIE
# fichier_a_reviser = Path(TEMPLATE_PATH.name.replace(".md", " - analyse à relire.md"))
# write_report(analyse, TEMP_SECTIONS / fichier_a_reviser)
# ingest_document(TEMP_SECTIONS / fichier_a_reviser)
full_prompt = """
Suivre scrupuleusement les consignes.
"""
system_message = f"""
Vous êtes un réviseur professionnel expert en écriture stratégique, maîtrisant parfaitement la langue française et habitué à réviser des textes destinés à des dirigeants de haut niveau (COMEX).
Votre tâche unique est d'améliorer strictement la qualité rédactionnelle du texte suivant, sans modifier en aucune manière :
- la structure existante (sections, titres, sous-titres),
- l'ordre des paragraphes et des idées,
- le sens précis du contenu original,
- sans ajouter aucune information nouvelle.
Votre révision doit impérativement respecter les points suivants :
- Éliminer toutes répétitions ou redondances et varier systématiquement les tournures entre les paragraphes.
- Rendre chaque phrase claire, directe et concise. Si une phrase est trop longue, scindez-la clairement en plusieurs phrases courtes.
- Structurer chaque paragraphe en 2 à 3 parties cohérentes, reliées entre elles par des termes logiques (coordination, implication, opposition, etc.) et séparées par des retours à la ligne.
- Remplacer systématiquement les acronymes par ces expressions précises :
- ICS « capacité à substituer un minerai »
- IHH « concentration géographique ou industrielle »
- ISG « stabilité géopolitique »
- IVC « concurrence intersectorielle pour les minerais »
Votre texte final doit être parfaitement fluide, agréable à lire, adapté à un COMEX, avec un ton professionnel et sobre.
**Important : Ne répondez strictement que par le texte révisé ci-dessous, sans aucun commentaire ou explication supplémentaire.**
Voici le texte à réviser précisément :
{analyse}
/no_think
"""
revision = generate_text("", full_prompt, system_message, "0.1", False).split("</think>")[-1].strip()
print("Relecture")
return revision
def supprimer_fichiers(session_uuid):
try:
delete_documents_by_criteria(session_uuid)
for temp_file in TEMP_SECTIONS.glob(f"*{session_uuid}*.md"):
temp_file.unlink()
return True
except:
return False
def generer_rapport_final(rapport, analyse, resultat):
try:
rapport = Path(rapport)
analyse = Path(analyse)
with zipfile.ZipFile(resultat, "w") as zipf:
zipf.write(rapport, arcname=rapport.name)
zipf.write(analyse, arcname=analyse.name)
return True
except Exception as e:
print(f"Erreur lors du zip : {e}")
return False

768
batch_ia/utils/sections.py Normal file
View File

@ -0,0 +1,768 @@
import os
import re
from .config import (
CORPUS_DIR,
TEMPLATE_PATH,
determine_threshold_color
)
from .files import (
find_prefixed_directory,
find_corpus_file,
write_report,
read_corpus_file
)
from .sections_utils import (
trouver_dossier_composant,
extraire_sections_par_mot_cle
)
def generate_introduction_section(data):
"""
Génère la section d'introduction du rapport.
"""
products = [p["label"] for p in data["products"].values()]
components = [c["label"] for c in data["components"].values()]
minerals = [m["label"] for m in data["minerals"].values()]
template = []
template.append("## Introduction\n")
template.append("Ce rapport analyse les vulnérabilités de la chaîne de fabrication du numérique pour :\n")
template.append("* les produits finaux : " + ", ".join(products))
template.append("* les composants : " + ", ".join(components))
template.append("* les minerais : " + ", ".join(minerals) + "\n")
return "\n".join(template)
def generate_methodology_section():
"""
Génère la section méthodologie du rapport.
"""
template = []
template.append("## Méthodologie d'analyse des risques\n")
template.append("### Indices et seuils\n")
template.append("La méthode d'évaluation intègre 4 indices et leurs combinaisons pour identifier les chemins critiques.\n")
# IHH
template.append("#### IHH (Herfindahl-Hirschmann) : concentration géographiques ou industrielle d'une opération\n")
# Essayer d'abord avec le chemin exact
ihh_context_file = "Criticités/Fiche technique IHH/00-contexte-et-objectif.md"
if os.path.exists(os.path.join(CORPUS_DIR, ihh_context_file)):
template.append(read_corpus_file(ihh_context_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ihh_context_file = find_corpus_file("contexte-et-objectif", "Criticités/Fiche technique IHH")
if ihh_context_file:
template.append(read_corpus_file(ihh_context_file, remove_first_title=True))
# Essayer d'abord avec le chemin exact
ihh_calc_file = "Criticités/Fiche technique IHH/01-mode-de-calcul/_intro.md"
if os.path.exists(os.path.join(CORPUS_DIR, ihh_calc_file)):
template.append(read_corpus_file(ihh_calc_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ihh_calc_file = find_corpus_file("mode-de-calcul/_intro", "Criticités/Fiche technique IHH")
if ihh_calc_file:
template.append(read_corpus_file(ihh_calc_file, remove_first_title=True))
template.append(" * Seuils : <15 = Vert (Faible), 15-25 = Orange (Modérée), >25 = Rouge (Élevée)\n")
# ISG
template.append("#### ISG (Stabilité Géopolitique) : stabilité des pays\n")
# Essayer d'abord avec le chemin exact
isg_context_file = "Criticités/Fiche technique ISG/00-contexte-et-objectif.md"
if os.path.exists(os.path.join(CORPUS_DIR, isg_context_file)):
template.append(read_corpus_file(isg_context_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
isg_context_file = find_corpus_file("contexte-et-objectif", "Criticités/Fiche technique ISG")
if isg_context_file:
template.append(read_corpus_file(isg_context_file, remove_first_title=True))
# Essayer d'abord avec le chemin exact
isg_calc_file = "Criticités/Fiche technique ISG/01-mode-de-calcul/_intro.md"
if os.path.exists(os.path.join(CORPUS_DIR, isg_calc_file)):
template.append(read_corpus_file(isg_calc_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
isg_calc_file = find_corpus_file("mode-de-calcul/_intro", "Criticités/Fiche technique ISG")
if isg_calc_file:
template.append(read_corpus_file(isg_calc_file, remove_first_title=True))
template.append(" * Seuils : <40 = Vert (Stable), 40-60 = Orange, >60 = Rouge (Instable)\n")
# ICS
template.append("#### ICS (Criticité de Substituabilité) : capacité à remplacer / substituer un élément\n")
# Essayer d'abord avec le chemin exact
ics_context_file = "Criticités/Fiche technique ICS/00-contexte-et-objectif.md"
if os.path.exists(os.path.join(CORPUS_DIR, ics_context_file)):
template.append(read_corpus_file(ics_context_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ics_context_file = find_corpus_file("contexte-et-objectif", "Criticités/Fiche technique ICS")
if ics_context_file:
template.append(read_corpus_file(ics_context_file, remove_first_title=True))
# Essayer d'abord avec le chemin exact
ics_calc_file = "Criticités/Fiche technique ICS/01-mode-de-calcul/_intro.md"
if os.path.exists(os.path.join(CORPUS_DIR, ics_calc_file)):
template.append(read_corpus_file(ics_calc_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ics_calc_file = find_corpus_file("mode-de-calcul/_intro", "Criticités/Fiche technique ICS")
if ics_calc_file:
template.append(read_corpus_file(ics_calc_file, remove_first_title=True))
template.append(" * Seuils : <0.3 = Vert (Facile), 0.3-0.6 = Orange (Moyenne), >0.6 = Rouge (Difficile)\n")
# IVC
template.append("#### IVC (Vulnérabilité de Concurrence) : pression concurrentielle avec d'autres secteurs\n")
# Essayer d'abord avec le chemin exact
ivc_context_file = "Criticités/Fiche technique IVC/00-contexte-et-objectif.md"
if os.path.exists(os.path.join(CORPUS_DIR, ivc_context_file)):
template.append(read_corpus_file(ivc_context_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ivc_context_file = find_corpus_file("contexte-et-objectif", "Criticités/Fiche technique IVC")
if ivc_context_file:
template.append(read_corpus_file(ivc_context_file, remove_first_title=True))
# Essayer d'abord avec le chemin exact
ivc_calc_file = "Criticités/Fiche technique IVC/01-mode-de-calcul/_intro.md"
if os.path.exists(os.path.join(CORPUS_DIR, ivc_calc_file)):
template.append(read_corpus_file(ivc_calc_file, remove_first_title=True))
else:
# Fallback à la recherche par motif
ivc_calc_file = find_corpus_file("mode-de-calcul/_intro", "Criticités/Fiche technique IVC")
if ivc_calc_file:
template.append(read_corpus_file(ivc_calc_file, remove_first_title=True))
template.append(" * Seuils : <5 = Vert (Faible), 5-15 = Orange (Modérée), >15 = Rouge (Forte)\n")
# Combinaison des indices
template.append("### Combinaison des indices\n")
# IHH et ISG
template.append("**IHH et ISG**\n")
template.append("Ces deux indices s'appliquent à toutes les opérations et se combinent dans l'évaluation du risque (niveau d'impact et probabilité de survenance) :\n")
template.append("* l'IHH donne le niveau d'impact => une forte concentration implique un fort impact si le risque est avéré")
template.append("* l'ISG donne la probabilité de survenance => plus les pays sont instables (et donc plus l'ISG est élevé) et plus la survenance du risque est élevée\n")
template.append("Pour évaluer le risque pour une opération, les ISG des pays sont pondérés par les parts de marché respectives pour donner un ISG combiné dont le calcul est :")
template.append("ISG_combiné = (Somme des ISG des pays multipliée par leur part de marché) / Sommes de leur part de marché\n")
template.append("On établit alors une matrice (Vert = 1, Orange = 2, Rouge = 3) et en faisant le produit des poids de l'ISG combiné et de l'IHH\n")
template.append("| ISG combiné / IHH | Vert | Orange | Rouge |")
template.append("| :-- | :-- | :-- | :-- |")
template.append("| Vert | 1 | 2 | 3 |")
template.append("| Orange | 2 | 4 | 6 |")
template.append("| Rouge | 3 | 6 | 9 |\n")
template.append("Les vulnérabilités se classent en trois niveaux pour chaque opération :\n")
template.append("* Vulnérabilité combinée élevée à critique : poids 6 et 9")
template.append("* Vulnérabilité combinée moyenne : poids 3 et 4")
template.append("* Vulnérabilité combinée faible : poids 1 et 2\n")
# ICS et IVC
template.append("**ICS et IVC**\n")
template.append("Ces deux indices se combinent dans l'évaluation du risque pour un minerai :\n")
template.append("* l'ICS donne le niveau d'impact => une faible substituabilité (et donc un ICS élevé) implique un fort impact si le risque est avéré ; l'ICS est associé à la relation entre un composant et un minerai")
template.append("* l'IVC donne la probabilité de l'impact => une forte concurrence intersectorielle (IVC élevé) implique une plus forte probabilité de survenance\n")
template.append("Par simplification, on intègre un ICS moyen d'un minerai comme étant la moyenne des ICS pour chacun des composants dans lesquels il intervient.\n")
template.append("On établit alors une matrice (Vert = 1, Orange = 2, Rouge = 3) et en faisant le produit des poids de l'ICS moyen et de l'IVC.\n")
template.append("| ICS_moyen / IVC | Vert | Orange | Rouge |")
template.append("| :-- | :-- | :-- | :-- |")
template.append("| Vert | 1 | 2 | 3 |")
template.append("| Orange | 2 | 4 | 6 |")
template.append("| Rouge | 3 | 6 | 9 |\n")
template.append("Les vulnérabilités se classent en trois niveaux pour chaque minerai :\n")
template.append("* Vulnérabilité combinée élevée à critique : poids 6 et 9")
template.append("* Vulnérabilité combinée moyenne : poids 3 et 4")
template.append("* Vulnérabilité combinée faible : poids 1 et 2\n")
return "\n".join(template)
def generate_operations_section(data, results, config):
"""
Génère la section détaillant les opérations (assemblage, fabrication, extraction, traitement).
"""
# # print("DEBUG: Génération de la section des opérations")
# # print(f"DEBUG: Nombre de produits: {len(data['products'])}")
# # print(f"DEBUG: Nombre de composants: {len(data['components'])}")
# # print(f"DEBUG: Nombre d'opérations: {len(data['operations'])}")
template = []
template.append("## Détails des opérations\n")
# 1. Traiter les produits finaux (assemblage)
for product_id, product in data["products"].items():
# # print(f"DEBUG: Produit {product_id} ({product['label']}), assembly = {product['assembly']}")
if product["assembly"]:
template.append(f"### {product['label']} et Assemblage\n")
# Récupérer la présentation synthétique
# product_slug = product['label'].lower().replace(' ', '-')
sous_repertoire = f"{product['label']}"
if product["level"] == 0:
type = "Assemblage"
else:
type = "Connexe"
sous_repertoire = trouver_dossier_composant(sous_repertoire, type, "Fiche assemblage ")
product_slug = sous_repertoire.split(' ', 2)[2]
presentation_file = find_corpus_file("présentation-synthétique", f"{type}/Fiche assemblage {product_slug}")
if presentation_file:
template.append(read_corpus_file(presentation_file, remove_first_title=True))
template.append("")
# Récupérer les principaux assembleurs
assembleurs_file = find_corpus_file("principaux-assembleurs", f"{type}/Fiche assemblage {product_slug}")
if assembleurs_file:
template.append(read_corpus_file(assembleurs_file, shift_titles=2))
template.append("")
# ISG des pays impliqués
assembly_id = product["assembly"]
operation = data["operations"][assembly_id]
template.append("##### ISG des pays impliqués\n")
template.append("| Pays | Part de marché | ISG | Criticité |")
template.append("| :-- | :-- | :-- | :-- |")
isg_weighted_sum = 0
total_share = 0
for country_id, share in operation["countries"].items():
country = data["countries"][country_id]
geo_country = country.get("geo_country")
if geo_country and geo_country in data["geo_countries"]:
isg_value = data["geo_countries"][geo_country]["isg"]
color, suffix = determine_threshold_color(isg_value, "ISG", config.get('thresholds'))
template.append(f"| {country['label']} | {share}% | {isg_value} | {color} ({suffix}) |")
isg_weighted_sum += isg_value * share
total_share += share
# Calculer ISG combiné
if total_share > 0:
isg_combined = isg_weighted_sum / total_share
color, suffix = determine_threshold_color(isg_combined, "ISG", config.get('thresholds'))
template.append(f"\n**ISG combiné: {isg_combined:.0f} - {color} ({suffix})**")
# IHH
ihh_file = find_corpus_file("matrice-des-risques-liés-à-l-assemblage/indice-de-herfindahl-hirschmann", f"{type}/Fiche assemblage {product_slug}")
if ihh_file:
template.append(read_corpus_file(ihh_file, shift_titles=1))
template.append("\n")
# Vulnérabilité combinée
if assembly_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][assembly_id]
template.append("#### Vulnérabilité combinée IHH-ISG\n")
template.append(f"* IHH: {combined['ihh_value']} - {combined['ihh_color']} ({combined['ihh_suffix']})")
template.append(f"* ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']} ({combined['isg_suffix']})")
template.append(f"* Poids combiné: {combined['combined_weight']}")
template.append(f"* Niveau de vulnérabilité: **{combined['vulnerability']}**\n")
# 2. Traiter les composants (fabrication)
for component_id, component in data["components"].items():
# # print(f"DEBUG: Composant {component_id} ({component['label']}), manufacturing = {component['manufacturing']}")
if component["manufacturing"]:
template.append(f"### {component['label']} et Fabrication\n")
# Récupérer la présentation synthétique
# component_slug = component['label'].lower().replace(' ', '-')
sous_repertoire = f"{component['label']}"
sous_repertoire = trouver_dossier_composant(sous_repertoire, "Fabrication", "Fiche fabrication ")
component_slug = sous_repertoire.split(' ', 2)[2]
presentation_file = find_corpus_file("présentation-synthétique", f"Fabrication/Fiche fabrication {component_slug}")
if presentation_file:
template.append(read_corpus_file(presentation_file, remove_first_title=True))
template.append("\n")
# Récupérer les principaux fabricants
fabricants_file = find_corpus_file("principaux-fabricants", f"Fabrication/Fiche fabrication {component_slug}")
if fabricants_file:
template.append(read_corpus_file(fabricants_file, shift_titles=2))
template.append("\n")
# ISG des pays impliqués
manufacturing_id = component["manufacturing"]
operation = data["operations"][manufacturing_id]
template.append("##### ISG des pays impliqués\n")
template.append("| Pays | Part de marché | ISG | Criticité |")
template.append("| :-- | :-- | :-- | :-- |")
isg_weighted_sum = 0
total_share = 0
for country_id, share in operation["countries"].items():
country = data["countries"][country_id]
geo_country = country.get("geo_country")
if geo_country and geo_country in data["geo_countries"]:
isg_value = data["geo_countries"][geo_country]["isg"]
color, suffix = determine_threshold_color(isg_value, "ISG", config.get('thresholds'))
template.append(f"| {country['label']} | {share}% | {isg_value} | {color} ({suffix}) |")
isg_weighted_sum += isg_value * share
total_share += share
# Calculer ISG combiné
if total_share > 0:
isg_combined = isg_weighted_sum / total_share
color, suffix = determine_threshold_color(isg_combined, "ISG", config.get('thresholds'))
template.append(f"\n**ISG combiné: {isg_combined:.0f} - {color} ({suffix})**\n\n")
# IHH
ihh_file = find_corpus_file("matrice-des-risques-liés-à-la-fabrication/indice-de-herfindahl-hirschmann", f"Fabrication/Fiche fabrication {component_slug}")
if ihh_file:
template.append(read_corpus_file(ihh_file, shift_titles=1))
template.append("\n")
# Vulnérabilité combinée
if manufacturing_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][manufacturing_id]
template.append("#### Vulnérabilité combinée IHH-ISG\n")
template.append(f"* IHH: {combined['ihh_value']} - {combined['ihh_color']} ({combined['ihh_suffix']})")
template.append(f"* ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']} ({combined['isg_suffix']})")
template.append(f"* Poids combiné: {combined['combined_weight']}")
template.append(f"* Niveau de vulnérabilité: **{combined['vulnerability']}**\n")
# 3. Traiter les minerais (détaillés dans une section séparée)
result = "\n".join(template)
# # print(f"DEBUG: Fin de génération de la section des opérations. Taille: {len(result)} caractères")
if len(result) <= 30: # Juste le titre de section
# # print("DEBUG: ALERTE - La section des opérations est vide ou presque vide!")
# Ajout d'une section de débogage dans le rapport
template.append("### DÉBOGAGE - Opérations manquantes\n")
template.append("Aucune opération d'assemblage ou de fabrication n'a été trouvée dans les données.\n")
template.append("Informations disponibles:\n")
template.append(f"* Nombre de produits: {len(data['products'])}\n")
template.append(f"* Nombre de composants: {len(data['components'])}\n")
template.append(f"* Nombre d'opérations: {len(data['operations'])}\n")
template.append("\nDétail des produits et de leurs opérations d'assemblage:\n")
for pid, p in data["products"].items():
template.append(f"* {p['label']}: {'Assemblage: ' + str(p['assembly']) if p['assembly'] else 'Pas d\'assemblage'}\n")
template.append("\nDétail des composants et de leurs opérations de fabrication:\n")
for cid, c in data["components"].items():
template.append(f"* {c['label']}: {'Fabrication: ' + str(c['manufacturing']) if c['manufacturing'] else 'Pas de fabrication'}\n")
result = "\n".join(template)
return result
def generate_minerals_section(data, results, config):
"""
Génère la section détaillant les minerais et leurs opérations d'extraction et traitement.
"""
template = []
template.append("## Détails des minerais\n")
for mineral_id, mineral in data["minerals"].items():
mineral_slug = mineral['label'].lower().replace(' ', '-')
fiche_dir = f"{CORPUS_DIR}/Minerai/Fiche minerai {mineral_slug}"
if not os.path.exists(fiche_dir):
continue
template.append(f"---\n\n### {mineral['label']}\n")
# Récupérer la présentation synthétique
presentation_file = find_corpus_file("présentation-synthétique", f"Minerai/Fiche minerai {mineral_slug}")
if presentation_file:
template.append(read_corpus_file(presentation_file, remove_first_title=True))
template.append("\n")
# ICS
template.append("#### ICS\n")
ics_intro_file = find_corpus_file("risque-de-substituabilité/_intro", f"Minerai/Fiche minerai {mineral_slug}")
if ics_intro_file:
template.append(read_corpus_file(ics_intro_file, remove_first_title=True))
template.append("\n")
# Calcul de l'ICS moyen
ics_values = list(mineral["ics_values"].values())
if ics_values:
ics_average = sum(ics_values) / len(ics_values)
color, suffix = determine_threshold_color(ics_average, "ICS", config.get('thresholds'))
template.append("##### Valeurs d'ICS par composant concerné\n")
template.append("| Composant | ICS | Criticité |")
template.append("| :-- | :-- | :-- |")
for comp_id, ics_value in mineral["ics_values"].items():
comp_name = data["components"][comp_id]["label"]
comp_color, comp_suffix = determine_threshold_color(ics_value, "ICS", config.get('thresholds'))
template.append(f"| {comp_name} | {ics_value:.2f} | {comp_color} ({comp_suffix}) |")
template.append(f"\n**ICS moyen : {ics_average:.2f} - {color} ({suffix})**\n")
# IVC
template.append("#### IVC\n\n")
# Valeur IVC
ivc_value = mineral.get("ivc", 0)
color, suffix = determine_threshold_color(ivc_value, "IVC", config.get('thresholds'))
template.append(f"**IVC: {ivc_value} - {color} ({suffix})**\n")
# Récupérer toutes les sections de vulnérabilité de concurrence
ivc_sections = []
ivc_dir = find_prefixed_directory("vulnérabilité-de-concurrence", f"Minerai/Fiche minerai {mineral_slug}")
corpus_path = os.path.join(CORPUS_DIR, ivc_dir) if os.path.exists(os.path.join(CORPUS_DIR, ivc_dir)) else None
if corpus_path:
for file in sorted(os.listdir(corpus_path)):
if file.endswith('.md') and "_intro.md" not in file and "sources" not in file:
ivc_sections.append(os.path.join(ivc_dir, file))
# Inclure chaque section IVC
for section_file in ivc_sections:
content = read_corpus_file(section_file, remove_first_title=False)
# Nettoyer les balises des fichiers IVC
content = re.sub(r'```.*?```', '', content, flags=re.DOTALL)
# Mettre le titre en italique s'il commence par un # (format Markdown pour titre)
if content and '\n' in content:
first_line, rest = content.split('\n', 1)
if first_line.strip().startswith('#'):
# Extraire le texte du titre sans les # et les espaces
title_text = first_line.strip().lstrip('#').strip()
content = f"\n*{title_text}*\n\n{rest.strip()}\n"
template.append(content)
# ICS et IVC combinés
if mineral_id in results["ics_ivc_combined"]:
combined = results["ics_ivc_combined"][mineral_id]
template.append("\n#### Vulnérabilité combinée ICS-IVC\n")
template.append(f"* ICS moyen: {combined['ics_average']:.2f} - {combined['ics_color']} ({combined['ics_suffix']})")
template.append(f"* IVC: {combined['ivc_value']} - {combined['ivc_color']} ({combined['ivc_suffix']})")
template.append(f"* Poids combiné: {combined['combined_weight']}")
template.append(f"* Niveau de vulnérabilité: **{combined['vulnerability']}**\n")
# Extraction
if mineral["extraction"]:
template.append("#### Extraction\n")
# Récupérer les principaux producteurs
producers_file = find_corpus_file("principaux-producteurs-extraction", f"Minerai/Fiche minerai {mineral_slug}")
if producers_file:
template.append(read_corpus_file(producers_file, remove_first_title=True))
template.append("\n")
# ISG des pays impliqués
extraction_id = mineral["extraction"]
operation = data["operations"][extraction_id]
template.append("##### ISG des pays impliqués\n")
template.append("| Pays | Part de marché | ISG | Criticité |")
template.append("| :-- | :-- | :-- | :-- |")
isg_weighted_sum = 0
total_share = 0
for country_id, share in operation["countries"].items():
country = data["countries"][country_id]
geo_country = country.get("geo_country")
if geo_country and geo_country in data["geo_countries"]:
isg_value = data["geo_countries"][geo_country]["isg"]
color, suffix = determine_threshold_color(isg_value, "ISG", config.get('thresholds'))
template.append(f"| {country['label']} | {share}% | {isg_value} | {color} ({suffix}) |")
isg_weighted_sum += isg_value * share
total_share += share
# Calculer ISG combiné
if total_share > 0:
isg_combined = isg_weighted_sum / total_share
color, suffix = determine_threshold_color(isg_combined, "ISG", config.get('thresholds'))
template.append(f"\n**ISG combiné: {isg_combined:.0f} - {color} ({suffix})**\n")
# IHH extraction
ihh_file = find_corpus_file("matrice-des-risques/indice-de-herfindahl-hirschmann-extraction", f"Minerai/Fiche minerai {mineral_slug}")
if ihh_file:
template.append(read_corpus_file(ihh_file, shift_titles=1))
template.append("\n")
# Vulnérabilité combinée
if extraction_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][extraction_id]
template.append("##### Vulnérabilité combinée IHH-ISG pour l'extraction\n")
template.append(f"* IHH: {combined['ihh_value']} - {combined['ihh_color']} ({combined['ihh_suffix']})")
template.append(f"* ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']} ({combined['isg_suffix']})")
template.append(f"* Poids combiné: {combined['combined_weight']}")
template.append(f"* Niveau de vulnérabilité: **{combined['vulnerability']}**\n")
# Traitement
if mineral["treatment"]:
template.append("#### Traitement\n")
# Récupérer les principaux producteurs
producers_file = find_corpus_file("principaux-producteurs-traitement", f"Minerai/Fiche minerai {mineral_slug}")
if producers_file:
template.append(read_corpus_file(producers_file, remove_first_title=True))
template.append("\n")
# ISG des pays impliqués
treatment_id = mineral["treatment"]
operation = data["operations"][treatment_id]
template.append("##### ISG des pays impliqués\n")
template.append("| Pays | Part de marché | ISG | Criticité |")
template.append("| :-- | :-- | :-- | :-- |")
isg_weighted_sum = 0
total_share = 0
for country_id, share in operation["countries"].items():
country = data["countries"][country_id]
geo_country = country.get("geo_country")
if geo_country and geo_country in data["geo_countries"]:
isg_value = data["geo_countries"][geo_country]["isg"]
color, suffix = determine_threshold_color(isg_value, "ISG", config.get('thresholds'))
template.append(f"| {country['label']} | {share}% | {isg_value} | {color} ({suffix}) |")
isg_weighted_sum += isg_value * share
total_share += share
# Calculer ISG combiné
if total_share > 0:
isg_combined = isg_weighted_sum / total_share
color, suffix = determine_threshold_color(isg_combined, "ISG", config.get('thresholds'))
template.append(f"\n**ISG combiné: {isg_combined:.0f} - {color} ({suffix})**\n")
# IHH traitement
ihh_file = find_corpus_file("matrice-des-risques/indice-de-herfindahl-hirschmann-traitement", f"Minerai/Fiche minerai {mineral_slug}")
if ihh_file:
template.append(read_corpus_file(ihh_file, shift_titles=1))
template.append("\n")
# Vulnérabilité combinée
if treatment_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][treatment_id]
template.append("##### Vulnérabilité combinée IHH-ISG pour le traitement\n")
template.append(f"* IHH: {combined['ihh_value']} - {combined['ihh_color']} ({combined['ihh_suffix']})")
template.append(f"* ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']} ({combined['isg_suffix']})")
template.append(f"* Poids combiné: {combined['combined_weight']}")
template.append(f"* Niveau de vulnérabilité: **{combined['vulnerability']}**\n")
return "\n".join(template)
def generate_critical_paths_section(data, results):
"""
Génère la section des chemins critiques.
"""
template = []
template.append("## Chemins critiques\n")
# Récupérer les chaînes par niveau de risque
critical_chains = []
major_chains = []
medium_chains = []
for chain in results["chains"]:
if chain["risk_level"] == "critique":
critical_chains.append(chain)
elif chain["risk_level"] == "majeur":
major_chains.append(chain)
elif chain["risk_level"] == "moyen":
medium_chains.append(chain)
# 1. Chaînes critiques
template.append("### Chaînes avec risque critique\n")
template.append("*Ces chaînes comprennent au moins une vulnérabilité combinée élevée à critique*\n")
if critical_chains:
for chain in critical_chains:
product_name = data["products"][chain["product"]]["label"]
component_name = data["components"][chain["component"]]["label"]
mineral_name = data["minerals"][chain["mineral"]]["label"]
template.append(f"#### {product_name}{component_name}{mineral_name}\n")
# Vulnérabilités
template.append("**Vulnérabilités identifiées:**\n")
for vuln in chain["vulnerabilities"]:
vuln_type = vuln["type"].capitalize()
vuln_level = vuln["vulnerability"]
if vuln_type == "Minerai":
mineral_id = vuln["mineral_id"]
template.append(f"* {vuln_type} ({mineral_name}): {vuln_level}")
if mineral_id in results["ics_ivc_combined"]:
combined = results["ics_ivc_combined"][mineral_id]
template.append(f" * ICS moyen: {combined['ics_average']:.2f} - {combined['ics_color']}")
template.append(f" * IVC: {combined['ivc_value']} - {combined['ivc_color']}")
else:
op_id = vuln["operation_id"]
op_label = data["operations"][op_id]["label"]
template.append(f"* {vuln_type} ({op_label}): {vuln_level}")
if op_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][op_id]
template.append(f" * IHH: {combined['ihh_value']} - {combined['ihh_color']}")
template.append(f" * ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']}")
template.append("\n")
else:
template.append("Aucune chaîne à risque critique identifiée.\n")
# 2. Chaînes majeures
template.append("### Chaînes avec risque majeur\n")
template.append("*Ces chaînes comprennent au moins trois vulnérabilités combinées moyennes*\n")
if major_chains:
for chain in major_chains:
product_name = data["products"][chain["product"]]["label"]
component_name = data["components"][chain["component"]]["label"]
mineral_name = data["minerals"][chain["mineral"]]["label"]
template.append(f"#### {product_name}{component_name}{mineral_name}\n")
# Vulnérabilités
template.append("**Vulnérabilités identifiées:**\n")
for vuln in chain["vulnerabilities"]:
vuln_type = vuln["type"].capitalize()
vuln_level = vuln["vulnerability"]
if vuln_type == "Minerai":
mineral_id = vuln["mineral_id"]
template.append(f"* {vuln_type} ({mineral_name}): {vuln_level}\n")
if mineral_id in results["ics_ivc_combined"]:
combined = results["ics_ivc_combined"][mineral_id]
template.append(f" * ICS moyen: {combined['ics_average']:.2f} - {combined['ics_color']}\n")
template.append(f" * IVC: {combined['ivc_value']} - {combined['ivc_color']}\n")
else:
op_id = vuln["operation_id"]
op_label = data["operations"][op_id]["label"]
template.append(f"* {vuln_type} ({op_label}): {vuln_level}\n")
if op_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][op_id]
template.append(f" * IHH: {combined['ihh_value']} - {combined['ihh_color']}\n")
template.append(f" * ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']}\n")
template.append("\n")
else:
template.append("Aucune chaîne à risque majeur identifiée.\n")
# 3. Chaînes moyennes
template.append("### Chaînes avec risque moyen\n")
template.append("*Ces chaînes comprennent au moins une vulnérabilité combinée moyenne*\n")
if medium_chains:
for chain in medium_chains:
product_name = data["products"][chain["product"]]["label"]
component_name = data["components"][chain["component"]]["label"]
mineral_name = data["minerals"][chain["mineral"]]["label"]
template.append(f"#### {product_name}{component_name}{mineral_name}\n")
# Vulnérabilités
template.append("**Vulnérabilités identifiées:**\n")
for vuln in chain["vulnerabilities"]:
vuln_type = vuln["type"].capitalize()
vuln_level = vuln["vulnerability"]
if vuln_type == "Minerai":
mineral_id = vuln["mineral_id"]
template.append(f"* {vuln_type} ({mineral_name}): {vuln_level}\n")
if mineral_id in results["ics_ivc_combined"]:
combined = results["ics_ivc_combined"][mineral_id]
template.append(f" * ICS moyen: {combined['ics_average']:.2f} - {combined['ics_color']}\n")
template.append(f" * IVC: {combined['ivc_value']} - {combined['ivc_color']}\n")
else:
op_id = vuln["operation_id"]
op_label = data["operations"][op_id]["label"]
template.append(f"* {vuln_type} ({op_label}): {vuln_level}\n")
if op_id in results["ihh_isg_combined"]:
combined = results["ihh_isg_combined"][op_id]
template.append(f" * IHH: {combined['ihh_value']} - {combined['ihh_color']}\n")
template.append(f" * ISG combiné: {combined['isg_combined']:.0f} - {combined['isg_color']}\n")
template.append("\n")
else:
template.append("Aucune chaîne à risque moyen identifiée.\n")
return "\n".join(template)
def slugify(text):
return re.sub(r'\W+', '-', text.strip()).strip('-').lower()
def generate_report(data, results, config):
"""
Génère le rapport complet structuré selon les spécifications.
"""
# Titre principal
report_titre = ["# Évaluation des vulnérabilités critiques\n"]
# Section d'introduction
report_introduction = generate_introduction_section(data)
# report.append(generate_introduction_section(data))
# Section méthodologie
report_methodologie = generate_methodology_section()
# report.append(generate_methodology_section())
# Section détails des opérations
report_operations = generate_operations_section(data, results, config)
# report.append(generate_operations_section(data, results, config))
# Section détails des minerais
report_minerals = generate_minerals_section(data, results, config)
# report.append(generate_minerals_section(data, results, config))
# Section chemins critiques
report_critical_paths = generate_critical_paths_section(data, results)
suffixe = " - chemins critiques"
fichier = TEMPLATE_PATH.name.replace(".md", f"{suffixe}.md")
fichier_path = TEMPLATE_PATH.parent / fichier
# Élever les titres Markdown dans report_critical_paths
report_critical_paths = re.sub(r'^(#{2,})', lambda m: '#' * (len(m.group(1)) - 1), report_critical_paths, flags=re.MULTILINE)
write_report(report_critical_paths, fichier_path)
# Récupérer les sections critiques décomposées par mot-clé
chemins_critiques_sections = extraire_sections_par_mot_cle(fichier_path)
file_names = []
# Pour chaque mot-clé, écrire un fichier individuel
for mot_cle, contenu in chemins_critiques_sections.items():
print(mot_cle)
mot_cle_slug = slugify(mot_cle)
suffixe = f" - chemins critiques {mot_cle_slug}"
fichier_personnalise = TEMPLATE_PATH.with_name(
TEMPLATE_PATH.name.replace(".md", f"{suffixe}.md")
)
# Ajouter du texte au début du contenu
introduction = f"# Détail des chemins critiques pour : {mot_cle}\n\n"
contenu = introduction + contenu
write_report(contenu, fichier_personnalise)
file_names.append(fichier_personnalise)
# report.append(generate_critical_paths_section(data, results))
# Ordre de composition final
report = (
report_titre +
[report_introduction] +
[report_critical_paths] +
[report_operations] +
[report_minerals] +
[report_methodologie]
)
return "\n".join(report), file_names

View File

@ -0,0 +1,92 @@
import os
import re
from pathlib import Path
from collections import defaultdict
from .config import (
CORPUS_DIR
)
def composant_match(nom_composant, nom_dossier):
"""
Vérifie si le nom du composant correspond approximativement à un nom de dossier (lettres et chiffres dans le même ordre).
"""
def clean(s):
return ''.join(c.lower() for c in s if c.isalnum())
cleaned_comp = clean(nom_composant)
cleaned_dir = clean(nom_dossier)
# Vérifie que chaque caractère de cleaned_comp est présent dans cleaned_dir dans le bon ordre
it = iter(cleaned_dir)
return all(c in it for c in cleaned_comp)
def trouver_dossier_composant(nom_composant, base_path, prefixe):
"""
Parcourt les sous-répertoires de base_path et retourne celui qui correspond au composant.
"""
search_path = os.path.join(CORPUS_DIR, base_path)
if not os.path.exists(search_path):
return None
for d in os.listdir(search_path):
if os.path.isdir(os.path.join(search_path, d)):
if composant_match(f"{prefixe}{nom_composant}", d):
return os.path.join(base_path, d)
return None
def extraire_sections_par_mot_cle(fichier_markdown: Path) -> dict:
"""
Extrait les sections de niveau 3 uniquement dans la section
'## Chaînes avec risque critique' du fichier Markdown,
et les regroupe par mot-clé (ce qui se trouve entre '### ' et '').
Réduit chaque titre dun niveau (#).
"""
with fichier_markdown.open(encoding="utf-8") as f:
contenu = f.read()
# Extraire uniquement la section '## Chaînes avec risque critique'
match_section = re.search(
r"## Chaînes avec risque critique(.*?)(?=\n## |\Z)", contenu, re.DOTALL
)
if not match_section:
return {}
section_critique = match_section.group(1)
# Extraire les mots-clés entre '### ' et ' →'
mots_cles = set(re.findall(r"^### (.+?) →", section_critique, re.MULTILINE))
# Extraire tous les blocs de niveau 3 dans cette section uniquement
blocs_sections = re.findall(r"(### .+?)(?=\n### |\n## |\Z)", section_critique, re.DOTALL)
# Regrouper les blocs par mot-clé
regroupement = defaultdict(list)
for bloc in blocs_sections:
match = re.match(r"### (.+?) →", bloc)
if match:
mot = match.group(1)
if mot in mots_cles:
# Réduction du niveau des titres
bloc_modifie = re.sub(r"^###", "##", bloc, flags=re.MULTILINE)
bloc_modifie = re.sub(r"^###", "##", bloc_modifie, flags=re.MULTILINE)
regroupement[mot].append(bloc_modifie)
return {mot: "\n\n".join(blocs) for mot, blocs in regroupement.items()}
def nettoyer_texte_fr(texte: str) -> str:
# Apostrophes droites -> typographiques
texte = texte.replace("'", "")
# Guillemets droits -> guillemets français (avec espace fine insécable)
texte = re.sub(r'"(.*?)"', r'« \1»', texte)
# Espaces fines insécables avant : ; ! ?
texte = re.sub(r' (?=[:;!?])', '\u202F', texte)
# Unités : espace insécable entre chiffre et unité
texte = re.sub(r'(\d) (?=\w+)', lambda m: f"{m.group(1)}\u202F", texte)
# Suppression des doubles espaces
texte = re.sub(r' {2,}', ' ', texte)
# Remplacement optionnel des tirets simples (optionnel)
texte = texte.replace(" - ", " ")
# Nettoyage ponctuation multiple accidentelle
texte = re.sub(r'\s+([.,;!?])', r'\1', texte)
return texte

View File

@ -10,11 +10,12 @@ Le module components comprend plusieurs fichiers clés :
- **header.py** : Composant d'en-tête unifié pour toutes les pages
- **footer.py** : Pied de page standardisé incluant les mentions légales et informations de contact
- **fiches.py** : Composants spécifiques à l'affichage et à la manipulation des fiches
- **connexion.py** : Composants spécifiques à la gestion de la connexion / déconnexion sur la base d'un token Gitea
## Fonctionnalités
### Barre latérale (sidebar.py)
- Menu de navigation principal entre les différentes sections
- Menu de navigation prin>>>>>>>>>>>>> cipal entre les différentes sections
- Options de configuration et de personnalisation
- Affichage des informations sur l'impact environnemental
- Gestion du thème (clair/sombre)
@ -45,4 +46,4 @@ afficher_menu()
afficher_pied_de_page()
```
Cette approche modulaire permet de maintenir une interface cohérente tout en facilitant les mises à jour de l'interface utilisateur.
Cette approche modulaire permet de maintenir une interface cohérente tout en facilitant les mises à jour de l'interface utilisateur.

View File

@ -3,6 +3,7 @@ import requests
import logging
import os
from utils.translations import _
from utils.persistance import get_champ_statut, maj_champ_statut
def initialiser_logger():
LOG_FILE_PATH = "/var/log/fabnum-auth.log"
@ -19,7 +20,8 @@ def initialiser_logger():
return logger
def connexion():
if "logged_in" not in st.session_state or not st.session_state.logged_in:
login = get_champ_statut("login")
if login == "":
auth_title = str(_("auth.title"))
st.html(f"""
<section role="region" aria-label="region-authentification">
@ -33,18 +35,14 @@ def connexion():
logger = initialiser_logger()
if "logged_in" not in st.session_state:
st.session_state.logged_in = False
st.session_state.username = ""
st.session_state.token = ""
if not st.session_state.logged_in:
if get_champ_statut("login") == "":
with st.form("auth_form"):
# Ajout d'un champ identifiant fictif pour activer l'autocomplétion navigateur
# et permettre de stocker le token comme un mot de passe par le navigateur
# L'identifiant n'est donc pas utilisé par la suite ; il est caché en CSS
identifiant = st.text_input(str(_("auth.username")), value="fabnum-connexion", key="nom_utilisateur")
token = st.text_input(str(_("auth.token")), type="password")
submitted = st.form_submit_button(str(_("auth.login")))
submitted = st.form_submit_button(str(_("auth.login")), icon=":material/login:")
if submitted and token:
erreur = True
@ -70,10 +68,8 @@ def connexion():
check_url = f"{GITEA_URL}/teams/{team_id}/members/{username}"
check_response = requests.get(check_url, headers=headers, timeout=5)
if check_response.status_code == 200:
st.session_state.logged_in = True
st.session_state.username = username
st.session_state.token = token
erreur = False
maj_champ_statut("login", username)
logger.info(f"Connexion réussie pour {username} depuis IP {ip}")
st.rerun()
@ -91,7 +87,8 @@ def connexion():
def bouton_deconnexion():
if st.session_state.get("logged_in", False):
login = get_champ_statut("login")
if not login == "":
auth_title = str(_("auth.title"))
st.html(f"""
<section role="region" aria-label="region-authentification">
@ -99,11 +96,9 @@ def bouton_deconnexion():
<p id="Authentification" class="decorative-heading">{auth_title}</p>
""")
st.sidebar.markdown(f"{str(_('auth.logged_as'))} `{st.session_state.username}`")
if st.sidebar.button(str(_("auth.logout"))):
st.session_state.logged_in = False
st.session_state.username = ""
st.session_state.token = ""
st.sidebar.markdown(f"{str(_('auth.logged_as'))} `{login}`")
if st.sidebar.button(str(_("auth.logout")), icon=":material/logout:"):
maj_champ_statut("login", "")
st.success(str(_("auth.success")))
st.rerun()

View File

@ -11,7 +11,9 @@ def afficher_pied_de_page():
<div role='contentinfo' aria-labelledby='footer-appli' class='wide-footer'>
<div class='info-footer'>
<p id='footer-appli' class='info-footer'>
{_("footer.copyright")} <a href='mailto:stephan-pro@peccini.fr'>{_("footer.contact")}</a> {_("footer.license")} <a href='https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr' target='_blank'>{_("footer.license_text")}</a>
{_("footer.copyright")} <a href='mailto:stephan.peccini@polycrisis-observatory.org'>{_("footer.contact")}</a> {_("footer.license")} <a href='https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr' target='_blank'>{_("footer.license_text")}</a>
</p>
<a href="https://www.polycrisis-observatory.org/" target="_blank"><img src="/app/static/images/polycrisis_observatory.webp" alt="Logo de l'Observatoire des Polycrises"></a>
</p>
<p class='footer-note'>
{_("footer.eco_note")} <a href='https://www.thegreenwebfoundation.org/' target='_blank'>{_("footer.eco_provider")}</a><br>

View File

@ -1,6 +1,7 @@
import streamlit as st
from config import ENV
from utils.translations import _
from utils.persistance import get_session_id
def afficher_entete():
@ -11,7 +12,7 @@ def afficher_entete():
"""
if ENV == "dev":
header += f"<p>🔧 {_("app.dev_mode")}</p>"
header += f"<p>🔧 {_("app.dev_mode")} Session : {get_session_id()}</p>"
else:
header += f"<p>{_("header.subtitle")}</p>"

View File

@ -2,7 +2,7 @@ import streamlit as st
from components.connexion import connexion, bouton_deconnexion
import streamlit.components.v1 as components
from utils.translations import _
from utils.persistance import get_champ_statut, maj_champ_statut
def afficher_menu():
with st.sidebar:
@ -13,20 +13,24 @@ def afficher_menu():
# Définir la variable instructions_text une seule fois en haut de la fonction
instructions_text = str(_("navigation.instructions"))
if "onglet" not in st.session_state:
st.session_state.onglet = instructions_text
navigation_onglet = get_champ_statut("navigation_onglet")
if navigation_onglet == "":
navigation_onglet = instructions_text
maj_champ_statut("navigation_onglet", navigation_onglet)
onglet_choisi = None
onglets = [
str(_("navigation.instructions")),
str(_("navigation.personnalisation")),
str(_("navigation.analyse")),
*([str(_("navigation.ia_nalyse"))] if not get_champ_statut("login") == "" else []),
*([str(_("navigation.plan_d_action"))]),
str(_("navigation.visualisations")),
str(_("navigation.fiches"))
]
for nom in onglets:
if st.session_state.onglet == nom:
if navigation_onglet == nom:
st.markdown(f'<div class="bouton-fictif">{nom}</div>', unsafe_allow_html=True)
else:
if st.button(str(nom)):
@ -43,63 +47,55 @@ def afficher_menu():
# Pour éviter de perdre les informations dans les formulaires,
# le changement de thème n'est proposé que si l'utilisateur est sur l'onglet "Instructions"
#
if st.session_state.onglet == instructions_text:
if "theme_mode" not in st.session_state:
st.session_state.theme_mode = str(_("sidebar.theme_light"))
#
theme_mode = get_champ_statut("theme_mode")
if theme_mode == "":
theme_mode = str(_("sidebar.theme_light"))
maj_champ_statut("theme_mode", theme_mode)
theme_title = str(_("sidebar.theme"))
st.markdown(f"""
<section role="region" aria-label="region-theme">
<div role="region" aria-labelledby="Theme">
<p id="Theme" class="decorative-heading">{theme_title}</p>
""", unsafe_allow_html=True)
theme_title = str(_("sidebar.theme"))
st.markdown(f"""
<section role="region" aria-label="region-theme">
<div role="region" aria-labelledby="Theme">
<p id="Theme" class="decorative-heading">{theme_title}</p>
""", unsafe_allow_html=True)
theme_options = [
str(_("sidebar.theme_light")),
str(_("sidebar.theme_dark"))
]
theme = st.radio(
str(_("sidebar.theme")),
theme_options,
index=theme_options.index(st.session_state.theme_mode),
horizontal=True,
label_visibility="hidden"
)
theme_options = [
str(_("sidebar.theme_light")),
str(_("sidebar.theme_dark"))
]
theme = st.radio(
str(_("sidebar.theme")),
theme_options,
index=theme_options.index(theme_mode),
horizontal=True,
label_visibility="hidden"
)
maj_champ_statut("theme_mode", theme)
st.markdown("""
<hr />
</div>
</nav>""", unsafe_allow_html=True)
else :
theme_title = str(_("sidebar.theme"))
st.markdown(f"""
<section role="region" aria-label="region-theme">
<div role="region" aria-labelledby="Theme">
<p id="Theme" class="decorative-heading">{theme_title}</p>
""", unsafe_allow_html=True)
st.info(str(_("sidebar.theme_instructions_only")))
st.markdown("""
<hr />
</div>
</nav>""", unsafe_allow_html=True)
theme = st.session_state.theme_mode
st.markdown("""
<hr />
</div>
</nav>""", unsafe_allow_html=True)
connexion()
if st.session_state.get("logged_in", False):
if not get_champ_statut("login") == "":
bouton_deconnexion()
# === RERUN SI BESOIN ===
if (onglet_choisi and onglet_choisi != st.session_state.onglet) or (theme != st.session_state.theme_mode):
if (onglet_choisi and onglet_choisi != navigation_onglet) or (theme != theme_mode):
if onglet_choisi: # Ne met à jour que si on a cliqué
st.session_state.onglet = onglet_choisi
st.session_state.theme_mode = theme
maj_champ_statut("navigation_onglet", onglet_choisi)
maj_champ_statut("theme_mode", theme)
st.rerun()
#
# Important :
# Avec Selinux, il faut donner les bons droits
#
# sudo chcon -Rt httpd_sys_content_t /chemin/d/acces/assets/
#
def afficher_impact(total_bytes):
impact_label = str(_("sidebar.impact"))
loading_text = str(_("sidebar.loading"))
@ -129,7 +125,7 @@ def afficher_impact(total_bytes):
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
color: #145a1a;
color: #072c6e;
text-align: center;
}}
span {{

View File

@ -1,15 +1,35 @@
import os
import streamlit as st
from dotenv import load_dotenv
load_dotenv(".env")
load_dotenv(".env.local", override=True)
# Fonction pour déterminer l'environnement à partir de l'en-tête X-Environment
def determine_environment():
# Valeur par défaut (si aucun en-tête n'est détecté)
environment = "dev"
# Si nous sommes dans une session Streamlit
if hasattr(st, 'context') and hasattr(st.context, 'headers'):
try:
# Lire directement l'en-tête X-Environment défini par Nginx : dev/public
nginx_env = st.context.headers.get("x-environment")
if nginx_env:
environment = nginx_env.lower()
except Exception as e:
st.error(f"Erreur lors de la lecture de l'en-tête X-Environment: {e}\nEnvironnement dev par défaut")
return environment
ENV = determine_environment()
GITEA_URL = os.getenv("GITEA_URL", "https://fabnum-git.peccini.fr/api/v1")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
ORGANISATION = os.getenv("ORGANISATION", "fabnum")
DEPOT_FICHES = os.getenv("DEPOT_FICHES", "fiches")
DEPOT_CODE = os.getenv("DEPOT_CODE", "code")
ENV = os.getenv("ENV")
ENV_CODE = os.getenv("ENV_CODE")
DOT_FILE = os.getenv("DOT_FILE")
INSTRUCTIONS = os.getenv("INSTRUCTIONS", "Instructions.md")

15
fabnum-dev.service Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=Fabnum Dev - Streamlit App
After=network.target
[Service]
User=fabnum
WorkingDirectory=/home/fabnum/fabnum-dev
ExecStart=/home/fabnum/fabnum-dev/venv/bin/streamlit run /home/fabnum/fabnum-dev
/fabnum.py --server.port 8502
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

View File

@ -1,8 +1,20 @@
import utils.persistance
utils.persistance.update_session_paths()
import streamlit as st
from utils.persistance import get_champ_statut, get_session_id
st.set_page_config(
page_title="Fabnum Analyse de chaîne",
page_icon="assets/weakness.png", # ajout
layout="centered",
initial_sidebar_state="expanded"
)
import re
# Configuration Gitea
from config import INSTRUCTIONS
from config import INSTRUCTIONS, ENV
from utils.gitea import (
charger_instructions_depuis_gitea
@ -11,6 +23,8 @@ from utils.gitea import (
# Import du module de traductions
from utils.translations import init_translations, _, set_language
from utils.widgets import html_expander
def afficher_instructions_avec_expanders(markdown_content):
"""
Affiche le contenu markdown avec les sections de niveau 2 (## Titre) dans des expanders
@ -49,8 +63,8 @@ def afficher_instructions_avec_expanders(markdown_content):
# Affichage dans un expander
status = True if i == 1 else False
with st.expander(f"## {titre_section}", expanded=status):
st.markdown(contenu_section, unsafe_allow_html=True)
# with st.expander(f"## {titre_section}", expanded=status):
html_expander(f"{titre_section}", content=contenu_section, open_by_default=status, details_class="details_introduction")
from utils.graph_utils import (
charger_graphe
@ -68,13 +82,8 @@ from app.fiches import interface_fiches
from app.visualisations import interface_visualisations
from app.personnalisation import interface_personnalisation
from app.analyse import interface_analyse
st.set_page_config(
page_title="Fabnum Analyse de chaîne",
page_icon="assets/weakness.png", # ajout
layout="centered",
initial_sidebar_state="expanded"
)
from app.ia_nalyse import interface_ia_nalyse
from app.plan_d_action.interface import interface_plan_d_action
# Initialisation des traductions (langue française par défaut)
init_translations()
@ -82,12 +91,18 @@ init_translations()
# Pour tester d'autres langues, décommenter cette ligne :
set_language("fr")
session_id = st.context.headers.get("x-session-id")
#
# Important
# Avec Selinux, il faut mettre les bons droits :
#
# sudo semanage fcontext -a -t var_log_t '/var/log/nginx/fabnum-public\.access\.log'
# sudo restorecon -v /var/log/nginx/fabnum-public.access.log
#
session_id = get_session_id()
def get_total_bytes_for_session(session_id):
total_bytes = 0
try:
with open("/var/log/nginx/fabnum-dev.access.log", "r") as f:
with open(f"/var/log/nginx/fabnum-{ENV}.access.log", "r") as f:
for line in f:
if session_id in line:
match = re.search(r'"GET.*?" \d+ (\d+)', line)
@ -121,7 +136,8 @@ def charger_theme():
}
# Thème en cours (conversion du nom traduit vers l'identifiant interne)
current_theme_display = st.session_state.get("theme_mode", "Clair").lower()
st.session_state["theme_mode"] = get_champ_statut("theme_mode")
current_theme_display = st.session_state.get("theme_mode").lower()
current_theme = theme_mapping.get(current_theme_display, "light") # Par défaut light si non trouvé
theme_css = st.session_state[f"theme_css_content_{current_theme}"]
@ -144,7 +160,7 @@ def fermer_page():
st.markdown("""</section>""", unsafe_allow_html=True)
st.markdown("</main>", unsafe_allow_html=True)
total_bytes = get_total_bytes_for_session(session_id)
total_bytes = get_total_bytes_for_session(get_session_id())
afficher_pied_de_page()
afficher_impact(total_bytes)
@ -158,28 +174,44 @@ instructions_tab = _("navigation.instructions")
fiches_tab = _("navigation.fiches")
personnalisation_tab = _("navigation.personnalisation")
analyse_tab = _("navigation.analyse")
ia_nalyse_tab = _("navigation.ia_nalyse")
plan_d_action_tab = _("navigation.plan_d_action")
visualisations_tab = _("navigation.visualisations")
if st.session_state.onglet == instructions_tab:
navigation_onglet = get_champ_statut("navigation_onglet")
if navigation_onglet == instructions_tab:
markdown_content = charger_instructions_depuis_gitea(INSTRUCTIONS)
if markdown_content:
afficher_instructions_avec_expanders(markdown_content)
elif st.session_state.onglet == fiches_tab:
elif navigation_onglet == fiches_tab:
interface_fiches()
else:
# Charger le graphe une seule fois
# Le graphe n'est pas nécessaire pour Instructions ou Fiches
G_temp, G_temp_ivc, dot_file_path = charger_graphe()
dot_file_path = charger_graphe()
if dot_file_path and st.session_state.onglet == analyse_tab:
if dot_file_path and navigation_onglet == analyse_tab:
G_temp = st.session_state["G_temp"]
interface_analyse(G_temp)
elif dot_file_path and st.session_state.onglet == visualisations_tab:
elif dot_file_path and navigation_onglet == ia_nalyse_tab:
G_temp = st.session_state["G_temp"]
interface_ia_nalyse(G_temp)
elif dot_file_path and navigation_onglet == plan_d_action_tab:
G_temp = st.session_state["G_temp"]
interface_plan_d_action(G_temp)
elif dot_file_path and navigation_onglet == visualisations_tab:
G_temp = st.session_state["G_temp"]
G_temp_ivc = st.session_state["G_temp_ivc"]
interface_visualisations(G_temp, G_temp_ivc)
elif dot_file_path and st.session_state.onglet == personnalisation_tab:
elif dot_file_path and navigation_onglet == personnalisation_tab:
G_temp = st.session_state["G_temp"]
G_temp = interface_personnalisation(G_temp)
fermer_page()

View File

@ -1,24 +0,0 @@
#!/bin/bash
# Lancer fabnum avec Streamlit, selon l'environnement défini dans .env
# Aller dans le dossier du script
cd "$(dirname "$0")"
# Charger l'environnement Python
source venv/bin/activate
# Charger les variables d'environnement définies dans .env
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
else
echo "⚠️ Fichier .env manquant !"
exit 1
fi
# Valeur par défaut si PORT non défini
PORT=${PORT:-8501}
echo "🔄 Lancement de Fabnum ($ENV) sur le port $PORT..."
# Exécuter streamlit via l'interpréteur du venv
exec venv/bin/streamlit run fabnum.py --server.address=127.0.0.1 --server.port=$PORT

16
pgpt/.docker/router.yml Normal file
View File

@ -0,0 +1,16 @@
http:
services:
ollama:
loadBalancer:
healthCheck:
interval: 5s
path: /
servers:
- url: http://ollama-cpu:11434
- url: http://ollama-cuda:11434
- url: http://host.docker.internal:11434
routers:
ollama-router:
rule: "PathPrefix(`/`)"
service: ollama

51
pgpt/Dockerfile.ollama Normal file
View File

@ -0,0 +1,51 @@
FROM python:3.11.6-slim-bookworm AS base
# Install poetry
RUN pip install pipx
RUN python3 -m pipx ensurepath
RUN pipx install poetry==1.8.3
ENV PATH="/root/.local/bin:$PATH"
ENV PATH=".venv/bin/:$PATH"
# https://python-poetry.org/docs/configuration/#virtualenvsin-project
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
FROM base AS dependencies
WORKDIR /home/worker/app
COPY pyproject.toml poetry.lock ./
ARG POETRY_EXTRAS="ui vector-stores-qdrant llms-ollama embeddings-ollama"
RUN poetry install --no-root --extras "${POETRY_EXTRAS}"
FROM base AS app
ENV PYTHONUNBUFFERED=1
ENV PORT=8080
ENV APP_ENV=prod
ENV PYTHONPATH="$PYTHONPATH:/home/worker/app/private_gpt/"
EXPOSE 8080
# Prepare a non-root user
# More info about how to configure UIDs and GIDs in Docker:
# https://github.com/systemd/systemd/blob/main/docs/UIDS-GIDS.md
# Define the User ID (UID) for the non-root user
# UID 100 is chosen to avoid conflicts with existing system users
ARG UID=100
# Define the Group ID (GID) for the non-root user
# GID 65534 is often used for the 'nogroup' or 'nobody' group
ARG GID=65534
RUN adduser --system --gid ${GID} --uid ${UID} --home /home/worker worker
WORKDIR /home/worker/app
RUN chown worker /home/worker/app
RUN mkdir local_data && chown worker local_data
RUN mkdir models && chown worker models
COPY --chown=worker --from=dependencies /home/worker/app/.venv/ .venv
COPY --chown=worker private_gpt/ private_gpt
COPY --chown=worker *.yaml .
COPY --chown=worker scripts/ scripts
USER worker
ENTRYPOINT python -m private_gpt

105
pgpt/docker-compose.yaml Normal file
View File

@ -0,0 +1,105 @@
services:
#-----------------------------------
#---- Private-GPT services ---------
#-----------------------------------
private-gpt-ollama:
image: ${PGPT_IMAGE:-zylonai/private-gpt}:${PGPT_TAG:-0.6.2}-ollama
user: root
build:
context: .
dockerfile: Dockerfile.ollama
volumes:
- /home/fabnum/fabnum-dev/Fiches:/home/worker/app/local_data/Fiches:Z
ports:
- "127.0.0.1:8001:8001"
environment:
PORT: 8001
PGPT_PROFILES: docker
PGPT_MODE: ollama
PGPT_EMBED_MODE: ollama
PGPT_OLLAMA_API_BASE: http://ollama:11434
HF_TOKEN: ${HF_TOKEN:-}
profiles:
- ""
- ollama-cpu
- ollama-cuda
- ollama-api
depends_on:
ollama:
condition: service_started
private-gpt-llamacpp-cpu:
image: ${PGPT_IMAGE:-zylonai/private-gpt}:${PGPT_TAG:-0.6.2}-llamacpp-cpu
user: root
build:
context: .
dockerfile: Dockerfile.llamacpp-cpu
volumes:
- ./local_data/:/home/worker/app/local_data
- ./models/:/home/worker/app/models
entrypoint: sh -c ".venv/bin/python scripts/setup && .venv/bin/python -m private_gpt"
ports:
- "127.0.0.1:8001:8001"
environment:
PORT: 8001
PGPT_PROFILES: local
HF_TOKEN: ${HF_TOKEN:-}
profiles:
- llamacpp-cpu
#-----------------------------------
#---- Ollama services --------------
#-----------------------------------
ollama:
image: traefik:v2.10
ports:
- "127.0.0.1:8080:8080"
command:
- "--providers.file.filename=/etc/router.yml"
- "--log.level=ERROR"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:11434"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./.docker/router.yml:/etc/router.yml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- label:disable
profiles:
- ""
- ollama-cpu
- ollama-cuda
- ollama-api
ollama-cpu:
image: ollama/ollama:latest
ports:
- "127.0.0.1:11434:11434"
volumes:
- ./models:/root/.ollama:Z
healthcheck:
disable: true
profiles:
- ""
- ollama-cpu
ollama-cuda:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ./models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
profiles:
- ollama-cuda

View File

@ -0,0 +1,27 @@
"""private-gpt."""
import logging
import os
# Set to 'DEBUG' to have extensive logging turned on, even for libraries
ROOT_LOG_LEVEL = "INFO"
PRETTY_LOG_FORMAT = (
"%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)+25s - %(message)s"
)
logging.basicConfig(level=ROOT_LOG_LEVEL, format=PRETTY_LOG_FORMAT, datefmt="%H:%M:%S")
logging.captureWarnings(True)
# Disable gradio analytics
# This is done this way because gradio does not solely rely on what values are
# passed to gr.Blocks(enable_analytics=...) but also on the environment
# variable GRADIO_ANALYTICS_ENABLED. `gradio.strings` actually reads this env
# directly, so to fully disable gradio analytics we need to set this env var.
os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
# Disable chromaDB telemetry
# It is already disabled, see PR#1144
# os.environ["ANONYMIZED_TELEMETRY"] = "False"
# adding tiktoken cache path within repo to be able to run in offline environment.
os.environ["TIKTOKEN_CACHE_DIR"] = "tiktoken_cache"

View File

@ -0,0 +1,11 @@
# start a fastapi server with uvicorn
import uvicorn
from private_gpt.main import app
from private_gpt.settings.settings import settings
# Set log_config=None to do not use the uvicorn logging configuration, and
# use ours instead. For reference, see below:
# https://github.com/tiangolo/fastapi/discussions/7457#discussioncomment-5141108
uvicorn.run(app, host="0.0.0.0", port=settings().server.port, log_config=None)

Some files were not shown because too many files have changed in this diff Show More