diff --git a/.env b/.env
index e68ae04..d08598c 100644
--- a/.env
+++ b/.env
@@ -8,3 +8,7 @@ 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"
diff --git a/HTML/Assemblage/Fiche assemblage imprimante.html b/HTML/Assemblage/Fiche assemblage imprimante.html
index 4df7d86..f050ec1 100644
--- a/HTML/Assemblage/Fiche assemblage imprimante.html
+++ b/HTML/Assemblage/Fiche assemblage imprimante.html
@@ -16,7 +16,8 @@
-
Les imprimantes représentent un segment mature mais toujours essentiel du marché des périphériques informatiques, avec environ 80 millions d'unités produites annuellement. Ce marché englobe diverses technologies (jet d'encre, laser, thermique, 3D) destinées aux usages personnels, professionnels et industriels. L'assemblage des imprimantes présente des défis spécifiques liés à la précision mécanique, à l'intégration de systèmes électromécaniques complexes et à la nécessité d'une fiabilité élevée. Le processus comprend généralement le montage d'un châssis mécanique, l'installation des moteurs et systèmes d'entraînement, l'intégration de la tête d'impression ou du système laser, le montage de la carte mère et des composants électroniques, puis l'assemblage du boîtier extérieur. La production est répartie entre quelques acteurs majeurs, avec une concentration en Asie pour les modèles grand public et une fabrication plus distribuée pour les équipements professionnels et industriels.
+Présentation synthétique
Présentation synthétique
+Les imprimantes représentent un segment mature mais toujours essentiel du marché des périphériques informatiques, avec environ 80 millions d'unités produites annuellement. Ce marché englobe diverses technologies (jet d'encre, laser, thermique, 3D) destinées aux usages personnels, professionnels et industriels. L'assemblage des imprimantes présente des défis spécifiques liés à la précision mécanique, à l'intégration de systèmes électromécaniques complexes et à la nécessité d'une fiabilité élevée. Le processus comprend généralement le montage d'un châssis mécanique, l'installation des moteurs et systèmes d'entraînement, l'intégration de la tête d'impression ou du système laser, le montage de la carte mère et des composants électroniques, puis l'assemblage du boîtier extérieur. La production est répartie entre quelques acteurs majeurs, avec une concentration en Asie pour les modèles grand public et une fabrication plus distribuée pour les équipements professionnels et industriels.
Composants assemblés
Composants assemblés
diff --git a/HTML/Connexe/Fiche assemblage photolitographie DUV.html b/HTML/Connexe/Fiche assemblage photolitographie DUV.html
index 1bb9966..c7f40e1 100644
--- a/HTML/Connexe/Fiche assemblage photolitographie DUV.html
+++ b/HTML/Connexe/Fiche assemblage photolitographie DUV.html
@@ -1,7 +1,22 @@
-
-Fiche d’assemblage : Matériels de photolithographie DUV
-
-Description générale
Description générale
+
+Fiche assemblage Procédé Deep Ultraviolet
+
+
+
+| Version |
+Date |
+Commentaire |
+
+
+
+
+| 1.0 |
+2025-04-22 |
+Version initiale |
+
+
+
+Présentation synthétique
Présentation synthétique
Les scanners DUV (Deep Ultraviolet – 193 nm ArF immersion / 193 nm ArF sec / 248 nm KrF) couvrent les nœuds 28 nm à 7 nm (couches critiques) et les niveaux moins exigeants.
Un ArF immersion de dernière génération (TWINSCAN NXT:2100i) compte environ 55 000 pièces, pèse 115 t et coûte 90 – 140 M€.
Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de 45 M€.
@@ -12,7 +27,7 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de Démontage logistique (≈ 15–18 conteneurs)
Ré‑assemblage & qualification chez le fondeur (3–6 mois)
-
+
| Plateforme |
@@ -45,8 +60,7 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de 2019 –
-Description générale
-
+Présentation synthétique
Composants assemblés
Composants assemblés
@@ -103,9 +117,31 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de
Composants assemblés
Coûts indicatifs pour NXT:2100i (2024).
-
-Principaux assembleurs (livraisons 2024)
Principaux assembleurs (livraisons 2024)
-
+yaml
+Assemblage_ProcedeDUV:
+ PaysBas_Assemblage_ProcedeDUV:
+ nom_du_pays: Pays-Bas
+ part_de_marche: 84%
+ acteurs:
+ AMSL_PaysBas_Assemblage_ProcedeDUV:
+ nom_de_l_acteur: ASML
+ part_de_marche: 84%
+ pays_d_origine: Pays-Bas
+ Japon_Assemblage_ProcedeDUV:
+ nom_du_pays: Japon
+ part_de_marche: 16%
+ acteurs:
+ Nikon_Japon_Assemblage_ProcedeDUV:
+ nom_de_l_acteur: Nikon
+ part_de_marche: 12%
+ pays_d_origine: Japon
+ Canon_Japon_Assemblage_ProcedeDUV:
+ nom_de_l_acteur: Canon
+ part_de_marche: 4%
+ pays_d_origine: Japon
+Principaux assembleurs
Principaux assembleurs
+
+
| Pays d'implantation |
@@ -116,9 +152,9 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de
-| Pays‑Bas |
+Pays-Bas |
ASML |
-Pays‑Bas |
+Pays-Bas |
84 % |
@@ -146,9 +182,9 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de 16 %
-Principaux assembleurs (livraisons 2024)
-Total 2024 : ~ 240 DUV scanners (toutes longueurs d’onde) livrés, dont 90 % destinés à la Chine.
-
+Principaux assembleurs
+
+Total 2024 : ~ 240 DUV scanners (toutes longueurs d’onde) livrés, dont 90 % destinés à la Chine.
Contraintes spécifiques
Contraintes spécifiques
@@ -185,16 +221,14 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de OPEX source important
-Contraintes spécifiques
-
+Contraintes spécifiques
Logistique et transport
Logistique et transport
- 15–18 caisses (air + mer) ; modules ≤ 12 t
- Transport aérien Boeing 747‑8F / 777F, conteneurs maritimes 40’ HC
- Délai porte‑à‑porte : 45 jours (Europe → États‑Unis ou Japon → Corée)
- Assurance cargo typique 100 M$ par scanner
-
-
+
Durabilité et cycle de vie
Durabilité et cycle de vie
@@ -221,8 +255,7 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de 75 % masse métallique, CaF₂ recyclage dédié
-Durabilité et cycle de vie
-
+Durabilité et cycle de vie
Matrice des risques
Matrice des risques
@@ -260,9 +293,9 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de R3 : Qualité eau immersion impacte rendement et overlay.
- R4 : Retards fret aérien / maritime ; 18 caisses hors‑gabarit.
- R5 : 84 % des livraisons assurées par un seul acteur (ASML).
-
-Indice de Herfindahl-Hirschmann (HHI)
Indice de Herfindahl-Hirschmann (HHI)
-
+
+Indice de Herfindahl-Hirschmann (HHI)
+
| IHH |
@@ -281,22 +314,21 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de
Pays |
|
-73 |
|
+73 |
-Indice de Herfindahl-Hirschmann (HHI)
-Acteurs : ASML 84 %, Nikon 12 %, Canon 4 %
-Pays : Pays‑Bas + Japon dominants.
-
-En résumé
En résumé
+Matrice des risques
+IHH par entreprise (acteurs)
+L’IHH pour les assembleurs est de 72, ce qui indique une concentration extrêmement élevée. Seules trois entreprises produisent les machines de photolithographie Deep UV, dont ASML avec 84% de part de marché. La dépendance à ce seul acteur est un risque critique.
+IHH par pays
+L’IHH par pays atteint 73, révélant une concentration géographique extrêmement élevée. La répartition est dominée par les Pays-bas (84 %) et le Japon (16 %) représentant 100 % des capacités. Cette configuration expose la chaîne à des risques géopolitiques ou logistiques localisés.
+En résumé
-- Trois assembleurs (ASML, Nikon, Canon) mais ASML domine le segment ArF immersion.
-- Le laser excimère (Cymer, Gigaphoton) constitue la plus forte dépendance.
-- Les scanners DUV restent vendables à la Chine, ce qui oriente une grande partie de la production.
-- Principaux risques : capacité optiques CaF₂, disponibilité lasers, logistique trans‑Pacifique.
+- Le secteur présente une structure d’acteurs extrêmement concentrée (IHH 72)
+- La concentration géographique est extrêmement élevée (IHH 73)
-
+
Autres informations
Autres informations
@@ -343,8 +375,7 @@ Les KrF modernes (NSR‑S635E, Nikon) se vendent autour de Supervision constructeur
-Autres informations
-
+Autres informations
Sources techniques
Sources techniques
- ASML – Brochure « TWINSCAN NXT:2100i » (2024)
diff --git a/HTML/Connexe/Fiche assemblage photolitographie EUV.html b/HTML/Connexe/Fiche assemblage photolitographie EUV.html
index 0a6fcd3..e21e9b7 100644
--- a/HTML/Connexe/Fiche assemblage photolitographie EUV.html
+++ b/HTML/Connexe/Fiche assemblage photolitographie EUV.html
@@ -1,7 +1,22 @@
-
-Fiche d’assemblage : Matériels de photolithographie EUV
-
-Description générale
Description générale
+
+Fiche assemblage Procédé Extreme Ultraviolet
+
+
+
+| Version |
+Date |
+Commentaire |
+
+
+
+
+| 1.0 |
+2025-04-22 |
+Version initiale |
+
+
+
+Présentation synthétique
Présentation synthétique
Les scanners EUV (Extreme Ultra Violet – λ ≈ 13,5 nm) sont les équipements clés qui permettent de graver les nœuds < 7 nm.
Une machine de dernière génération (NXE:3800E) compte plus de 100 000 pièces, pèse 180 t et coûte 220–260 M€ (EXE > 350 M€ en High-NA) (ASML to pass tariff costs to US customers, gain three High NA EUV customers, ASML Is the Chip-Equipment Leader. Its Stock Is Poised to Bounce Back.).
Le flux d’assemblage se déroule en 4 grandes phases :
@@ -11,12 +26,31 @@ Le flux d’assemblage se déroule en 4 grandes phases :
- Démontage logistique (≈ 35 conteneurs + 3 avions cargo)
- Ré-assemblage & qualification chez le fondeur (6–9 mois)
-Les générations :
-| Plateforme | NA | Débit wafers/h | Commercialisation |
-| :-- | :--: | :--: | :-- |
-| NXE | 0,33 | 220 | 2019– |
-| EXE (High-NA) | 0,55 | 185 | 2024– (phase R&D)* |
-
+Les générations :
+
+
+
+| Plateforme |
+NA |
+Débit wafers/h |
+Commercialisation |
+
+
+
+
+| NXE |
+0,33 |
+220 |
+2019– |
+
+
+| EXE (High-NA) |
+0,55 |
+185* |
+2024– (phase R&D) |
+
+
+Présentation synthétique
Composants assemblés
Composants assemblés
@@ -73,9 +107,19 @@ Le flux d’assemblage se déroule en 4 grandes phases :
Composants assemblés
Coûts indicatifs pour NXE :3800E (2024).
-
-Principaux assembleurs (livraisons par an, 2024)
Principaux assembleurs (livraisons par an, 2024)
-
+
+Total 2024 : 55 NXE livrées, 5 EXE High-NA déjà en R&D chez Intel, TSMC, Samsung (ASML to pass tariff costs to US customers, gain three High NA EUV customers, Belgium's imec reports breakthroughs with new ASML chip printing machine).
Contraintes spécifiques
Contraintes spécifiques
@@ -137,16 +181,14 @@ Le flux d’assemblage se déroule en 4 grandes phases :
| Limite débit & rendement |
-Contraintes spécifiques
-
+Contraintes spécifiques
Logistique et transport
Logistique et transport
- 35 caisses (mer + air) ; modules > 10 t chacun
- Démontage en « kits » (< 22 t) pour Boeing 747-8F
- Délai porte-à-porte : 100 jours (Europe → Taïwan)
- Assurance cargo spécifique (valeur déclarée ≥ 250 M$)
-
-
+
Durabilité et cycle de vie
Durabilité et cycle de vie
@@ -173,8 +215,7 @@ Le flux d’assemblage se déroule en 4 grandes phases :
| 80 % masse métallique récupérable |
-Durabilité et cycle de vie
-
+Durabilité et cycle de vie
Matrice des risques
Matrice des risques
@@ -213,9 +254,9 @@ Le flux d’assemblage se déroule en 4 grandes phases :
- R4 : Goulot Zeiss pour miroirs 0,55 NA
- R5 : Dégâts transport, douanes hors gabarit
- R6 : Retard pellicle haute-NA réduit le yield
-
-Indice de Herfindahl-Hirschmann (HHI)
Indice de Herfindahl-Hirschmann (HHI)
-
+
+Indice de Herfindahl-Hirschmann (HHI)
+
| IHH |
@@ -238,18 +279,17 @@ Le flux d’assemblage se déroule en 4 grandes phases :
100 |
-Indice de Herfindahl-Hirschmann (HHI)
-Acteurs : ASML ≈ 100 % → monopole absolu.
-*Pays : chaîne dominée par les Pays-Bas.
-
-En résumé
En résumé
+Matrice des risques
+IHH par entreprise (acteurs)
+L’IHH pour les assembleurs est de 100, ce qui indique un monopole. ASML est le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.
+IHH par pays
+L’IHH par pays atteint 100, ce qui indique un monopole. Les Pays-Bas sont le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.
+En résumé
-- EUV = chaînon le plus concentré de toute la filière semi – dépendance critique à ASML/Zeiss.
-- La montée en High-NA (EXE) réduit le nombre d’expositions, mais renforce la dépendance.
-- Risque géopolitique majeur (export-control) et logistique complexe (35 conteneurs).
-- Les alternatives (NIL Canon, projets chinois) restent non qualifiées pour la production logic & memory < 3 nm.
+- Le secteur présente une structure monopolistique (IHH 100)
+- La concentration géographique est monopolistique (IHH 100)
-
+
Autres informations
Autres informations
-
-ASML ne possède pas d’usine secondaire pour l’assemblage final ; il expédie des « kits » depuis Veldhoven et supervise la reconstruction dans la salle blanche du client.
-
+Autres informations
Sources techniques
Sources techniques
- ASML – Fiches produits EUV (NXE/EXE) (EUV lithography systems – Products - ASML, 5 things you should know about High NA EUV lithography - ASML)
diff --git a/HTML/Criticités/Fiche technique IHH.html b/HTML/Criticités/Fiche technique IHH.html
index bc126f8..89cfd11 100644
--- a/HTML/Criticités/Fiche technique IHH.html
+++ b/HTML/Criticités/Fiche technique IHH.html
@@ -465,6 +465,76 @@
- Le secteur présente une structure d’acteurs plutôt diversifiée (IHH 13)
- La concentration géographique est élevée (IHH 30)
+Assemblage - ProcedeDUV
Assemblage - ProcedeDUV
+Indice de Herfindahl-Hirschmann (HHI)
+
+
+
+| IHH |
+Faible |
+Modéré |
+Élevé |
+
+
+
+
+| Acteurs |
+ |
+ |
+72 |
+
+
+| Pays |
+ |
+ |
+73 |
+
+
+Assemblage - ProcedeDUV
+IHH par entreprise (acteurs)
+L’IHH pour les assembleurs est de 72, ce qui indique une concentration extrêmement élevée. Seules trois entreprises produisent les machines de photolithographie Deep UV, dont ASML avec 84% de part de marché. La dépendance à ce seul acteur est un risque critique.
+IHH par pays
+L’IHH par pays atteint 73, révélant une concentration géographique extrêmement élevée. La répartition est dominée par les Pays-bas (84 %) et le Japon (16 %) représentant 100 % des capacités. Cette configuration expose la chaîne à des risques géopolitiques ou logistiques localisés.
+En résumé
+
+- Le secteur présente une structure d’acteurs extrêmement concentrée (IHH 72)
+- La concentration géographique est extrêmement élevée (IHH 73)
+
+Assemblage - ProcedeEUV
Assemblage - ProcedeEUV
+Indice de Herfindahl-Hirschmann (HHI)
+
+
+
+| IHH |
+Faible |
+Modéré |
+Élevé |
+
+
+
+
+| Acteurs |
+ |
+ |
+100 |
+
+
+| Pays |
+ |
+ |
+100 |
+
+
+Assemblage - ProcedeEUV
+IHH par entreprise (acteurs)
+L’IHH pour les assembleurs est de 100, ce qui indique un monopole. ASML est le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.
+IHH par pays
+L’IHH par pays atteint 100, ce qui indique un monopole. Les Pays-Bas sont le seul acteur sur ce domaine très complexe. La dépendance est un risque critique.
+En résumé
+
+- Le secteur présente une structure monopolistique (IHH 100)
+- La concentration géographique est monopolistique (IHH 100)
+
Fabrication - Audio
Fabrication - Audio
Indice de Herfindahl-Hirschmann
@@ -4680,6 +4750,16 @@
| ✅ 14 |
+| ProcedeDUV |
+🔴 73 |
+🔴 72 |
+
+
+| ProcedeEUV |
+🔴 100 |
+🔴 100 |
+
+
| Serveur |
🔴 34 |
✅ 12 |
diff --git a/components/fiches.py b/components/fiches.py
index f969bba..09826cf 100644
--- a/components/fiches.py
+++ b/components/fiches.py
@@ -1,21 +1,61 @@
+# === Constantes et imports ===
import streamlit as st
import requests
import re
-from config import GITEA_TOKEN
-from utils.gitea import charger_arborescence_fiches
-from utils.tickets_fiche import gerer_tickets_fiche
+import os
+import yaml
import markdown
from bs4 import BeautifulSoup
-from latex2mathml.converter import convert as latex_to_mathml
-from utils.fiche_utils import load_seuils, render_fiche_markdown
-from utils.fiche_dynamic import build_dynamic_sections, build_ivc_sections, build_ihh_sections, build_isg_sections, build_assemblage_sections
-import os
-from utils.gitea import recuperer_date_dernier_commit
from datetime import datetime, timezone
-import yaml
+from latex2mathml.converter import convert as latex_to_mathml
+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_URL, ORGANISATION, DEPOT_FICHES, ENV
+from config import GITEA_TOKEN, GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV, FICHES_CRITICITE
+from utils.gitea import charger_arborescence_fiches, recuperer_date_dernier_commit
+
+from utils.fiche_utils import load_seuils, render_fiche_markdown
+from utils.dynamic import (
+ build_dynamic_sections,
+ build_ivc_sections,
+ build_ihh_sections,
+ build_isg_sections,
+ build_production_sections,
+ build_minerai_sections
+)
+
+# === Logique métier ===
+def fichier_plus_recent(chemin_fichier, reference):
+ 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):
+ if not os.path.exists(html_path):
+ return True
+
+ local_mtime = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
+ remote_mtime = recuperer_date_dernier_commit(commit_url)
+
+ if remote_mtime is None or remote_mtime > local_mtime:
+ return True
+
+ if fichier_plus_recent(fichiers_criticite.get("IHH"), local_mtime):
+ return True
+
+ if fiche_type == "minerai" or "minerai" in fiche_choisie.lower():
+ if fichier_plus_recent(fichiers_criticite.get("IVC"), local_mtime):
+ return True
+ if fichier_plus_recent(fichiers_criticite.get("ICS"), local_mtime):
+ return True
+
+ return False
+
+# === Fonctions de transformation ===
def remplacer_latex_par_mathml(markdown_text):
def remplacer_bloc_display(match):
formule_latex = match.group(1).strip()
@@ -33,7 +73,6 @@ def remplacer_latex_par_mathml(markdown_text):
except Exception as e:
return f"Erreur LaTeX inline: {e}"
- # Important : d'abord les $$...$$, ensuite les $...$
markdown_text = re.sub(r"\$\$(.*?)\$\$", remplacer_bloc_display, markdown_text, flags=re.DOTALL)
markdown_text = re.sub(r"(? str:
- # Extraire bloc YAML d'en-tête
+# === Fonctions principales ===
+def creer_fiche(md_source, dossier, nom_fichier, seuils):
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
- context = {}
- if front_match:
- context = yaml.safe_load(front_match.group(1))
- #md_source = md_source[front_match.end():] # retirer le front-matter du corps
+ context = yaml.safe_load(front_match.group(1)) if front_match else {}
- # Traitement conditionnel selon type
type_fiche = context.get("type_fiche")
if type_fiche == "indice":
- if context.get("indice_court") == "ICS":
+ indice = context.get("indice_court")
+ if indice == "ICS":
md_source = build_dynamic_sections(md_source)
- elif context.get("indice_court") == "IVC":
+ elif indice == "IVC":
md_source = build_ivc_sections(md_source)
- elif context.get("indice_court") == "IHH":
+ elif indice == "IHH":
md_source = build_ihh_sections(md_source)
- elif context.get("indice_court") == "ISG":
+ elif indice == "ISG":
md_source = build_isg_sections(md_source)
- elif type_fiche == "assemblage" or type_fiche == "fabrication":
- md_source = build_assemblage_sections(md_source)
+ elif type_fiche in ["assemblage", "fabrication"]:
+ md_source = build_production_sections(md_source)
+ elif type_fiche == "minerai":
+ md_source = build_minerai_sections(md_source)
- # Rendu markdown principal
contenu_md = render_fiche_markdown(md_source, seuils)
-
- #st.code(md_source)
- #md_pairs = build_dynamic_sections(md_source)
- #contenu_md = render_fiche_markdown(md_pairs, seuils)
-
- # Sauvegarde .md
md_path = os.path.join("Fiches", dossier, nom_fichier)
os.makedirs(os.path.dirname(md_path), exist_ok=True)
with open(md_path, "w", encoding="utf-8") as f:
f.write(contenu_md)
- # Traitement en sections
lignes = contenu_md.split('\n')
sections_n1 = []
section_n1_actuelle = {"titre": None, "intro": [], "sections_n2": {}}
- dans_section_n1 = False
section_n2_actuelle = None
-
for ligne in lignes:
if re.match(r'^#[^#]', ligne):
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
sections_n1.append(section_n1_actuelle)
- section_n1_actuelle = {
- "titre": ligne.strip('# ').strip(),
- "intro": [],
- "sections_n2": {}
- }
+ section_n1_actuelle = {"titre": ligne.strip('# ').strip(), "intro": [], "sections_n2": {}}
section_n2_actuelle = None
- dans_section_n1 = True
elif re.match(r'^##[^#]', ligne):
section_n2_actuelle = ligne.strip('# ').strip()
section_n1_actuelle["sections_n2"][section_n2_actuelle] = [f"## {section_n2_actuelle}"]
elif section_n2_actuelle:
section_n1_actuelle["sections_n2"][section_n2_actuelle].append(ligne)
- elif dans_section_n1:
+ else:
section_n1_actuelle["intro"].append(ligne)
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
sections_n1.append(section_n1_actuelle)
- # Génération HTML
bloc_titre = sections_n1[0]["titre"] if sections_n1 and sections_n1[0]["titre"] else "fiche"
titre_id = re.sub(r'\W+', '-', bloc_titre.lower()).strip('-')
- html_output = [f'',
- f'{sections_n1[0]["titre"]}
']
-
+ html_output = [f'', f'{bloc_titre}
']
for bloc in sections_n1:
- if bloc["titre"] and bloc["titre"] != sections_n1[0]["titre"]:
+ if bloc["titre"] and bloc["titre"] != bloc_titre:
html_output.append(f"{bloc['titre']}
")
-
if bloc["intro"]:
intro_md = remplacer_latex_par_mathml("\n".join(bloc["intro"]))
- html_intro = markdown_to_html_rgaa(intro_md, caption_text=None)
+ html_intro = markdown_to_html_rgaa(intro_md)
html_output.append(html_intro)
-
for sous_titre, contenu in bloc["sections_n2"].items():
contenu_md = remplacer_latex_par_mathml("\n".join(contenu))
contenu_html = markdown_to_html_rgaa(contenu_md, caption_text=sous_titre)
html_output.append(f"{sous_titre}
{contenu_html} ")
html_output.append("")
-
html_dir = os.path.join("HTML", dossier)
os.makedirs(html_dir, exist_ok=True)
html_path = os.path.join(html_dir, os.path.splitext(nom_fichier)[0] + ".html")
-
with open(html_path, "w", encoding="utf-8") as f:
f.write("\n".join(html_output))
@@ -165,7 +179,6 @@ def afficher_fiches():
if dossier_choisi and dossier_choisi != "-- Sélectionner un dossier --":
fiches = arbo.get(dossier_choisi, [])
noms_fiches = [f['nom'] for f in fiches]
-
fiche_choisie = st.selectbox("Choisissez une fiche", ["-- Sélectionner une fiche --"] + noms_fiches)
if fiche_choisie and fiche_choisie != "-- Sélectionner une fiche --":
@@ -180,18 +193,20 @@ def afficher_fiches():
if "seuils" not in st.session_state:
SEUILS = load_seuils("assets/config.yaml")
st.session_state["seuils"] = SEUILS
- elif "seuils" in st.session_state:
+ else:
SEUILS = st.session_state["seuils"]
html_path = os.path.join("HTML", dossier_choisi, os.path.splitext(fiche_choisie)[0] + ".html")
path_relative = f"Documents/{dossier_choisi}/{fiche_choisie}"
commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative}&sha={ENV}"
- regenerate = True
- if os.path.exists(html_path):
- remote_last_modified = recuperer_date_dernier_commit(commits_url)
- local_last_modified = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
- regenerate = not remote_last_modified or remote_last_modified > local_last_modified
+ regenerate = doit_regenerer_fiche(
+ html_path=html_path,
+ fiche_type=fiche_info.get("type", ""),
+ fiche_choisie=fiche_choisie,
+ commit_url=commits_url,
+ fichiers_criticite=FICHES_CRITICITE
+ )
if regenerate:
st.info("DEBUG : Régénération de la fiche")
@@ -202,7 +217,10 @@ def afficher_fiches():
with open(html_path, "r", encoding="utf-8") as f:
st.markdown(f.read(), unsafe_allow_html=True)
- gerer_tickets_fiche(fiche_choisie)
+ st.markdown("
", unsafe_allow_html=True)
+ st.markdown("## Gestion des tickets pour cette fiche")
+ afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_choisie))
+ formulaire_creation_ticket_dynamique(fiche_choisie)
except Exception as e:
st.error(f"Erreur lors du chargement de la fiche : {e}")
diff --git a/config.py b/config.py
index 1811447..9128a24 100644
--- a/config.py
+++ b/config.py
@@ -13,3 +13,20 @@ ENV = os.getenv("ENV")
ENV_CODE = os.getenv("ENV_CODE")
DOT_FILE = os.getenv("DOT_FILE")
INSTRUCTIONS = os.getenv("INSTRUCTIONS", "Instructions.md")
+
+FICHE_IHH = os.getenv("FICHE_IHH")
+FICHE_ICS = os.getenv("FICHE_ICS")
+FICHE_IVC = os.getenv("FICHE_IVC")
+FICHE_ISG = os.getenv("FICHE_ISG")
+
+# Optionnel : vérification + fallback
+for key, value in [("FICHE_IHH", FICHE_IHH), ("FICHE_ICS", FICHE_ICS), ("FICHE_IVC", FICHE_IVC), ("FICHE_ISG", FICHE_ISG)]:
+ if not value:
+ raise EnvironmentError(f"Variable d'environnement '{key}' non définie.")
+
+FICHES_CRITICITE = {
+ "IHH": FICHE_IHH,
+ "IVC": FICHE_IVC,
+ "ICS": FICHE_ICS,
+ "ISG": FICHE_ISG
+}
diff --git a/utils/dynamic/README.md b/utils/dynamic/README.md
new file mode 100644
index 0000000..9757935
--- /dev/null
+++ b/utils/dynamic/README.md
@@ -0,0 +1,5 @@
+Ce répertoire contient le code nécessaire à la création d'une fiche, quel que soit son type.
+
+Les sous-répertoires correspondent aux différents types de fiche.
+
+Le sous-répertoire utils contient les parties communes du code.
diff --git a/utils/dynamic/__init__.py b/utils/dynamic/__init__.py
new file mode 100644
index 0000000..ef53820
--- /dev/null
+++ b/utils/dynamic/__init__.py
@@ -0,0 +1,14 @@
+# __init__.py
+
+from .indice.ics import build_dynamic_sections
+from .indice.ivc import build_ivc_sections
+from .indice.ihh import build_ihh_sections
+from .indice.isg import build_isg_sections
+from .assemblage_fabrication.production import build_production_sections
+from .minerai.minerai import (
+ build_minerai_sections,
+ build_minerai_ics_section,
+ build_minerai_ivc_section,
+ build_minerai_ics_composant_section
+)
+from .utils.pastille import pastille
diff --git a/utils/dynamic/assemblage_fabrication/production.py b/utils/dynamic/assemblage_fabrication/production.py
new file mode 100644
index 0000000..6568e9f
--- /dev/null
+++ b/utils/dynamic/assemblage_fabrication/production.py
@@ -0,0 +1,134 @@
+# production.py
+# Ce module gère à la fois les fiches d'assemblage ET de fabrication.
+
+import re
+import yaml
+import streamlit as st
+from config import FICHES_CRITICITE
+
+def build_production_sections(md: str) -> str:
+ schema = None
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ try:
+ front_matter = yaml.safe_load(front_match.group(1))
+ schema = front_matter.get("schema")
+ type_fiche = front_matter.get("type_fiche")
+ if type_fiche not in ["assemblage", "fabrication"] or not schema:
+ return md
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du front matter: {e}")
+ return md
+
+ yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
+ if not yaml_block:
+ return md
+
+ try:
+ yaml_data = yaml.safe_load(yaml_block.group(1))
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du YAML: {e}")
+ return md
+
+ if not isinstance(yaml_data, dict) or len(yaml_data) == 0:
+ return md
+
+ produit_key = list(yaml_data.keys())[0]
+ produit_data = yaml_data[produit_key]
+
+ pays_data = []
+ for pays_key, pays_info in produit_data.items():
+ nom_pays = pays_info.get('nom_du_pays', '')
+ part_marche_pays = pays_info.get('part_de_marche', '0%')
+ part_marche_num = float(part_marche_pays.strip('%'))
+
+ acteurs = []
+ for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
+ nom_acteur = acteur_info.get('nom_de_l_acteur', '')
+ part_marche_acteur = acteur_info.get('part_de_marche', '0%')
+ pays_origine = acteur_info.get('pays_d_origine', '')
+ part_marche_acteur_num = float(part_marche_acteur.strip('%'))
+
+ acteurs.append({
+ 'nom': nom_acteur,
+ 'part_marche': part_marche_acteur,
+ 'part_marche_num': part_marche_acteur_num,
+ 'pays_origine': pays_origine
+ })
+
+ acteurs_tries = sorted(acteurs, key=lambda x: x['part_marche_num'], reverse=True)
+ pays_data.append({
+ 'nom': nom_pays,
+ 'part_marche': part_marche_pays,
+ 'part_marche_num': part_marche_num,
+ 'acteurs': acteurs_tries
+ })
+
+ pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
+
+ lignes_tableau = [
+ "| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
+ "| :-- | :-- | :-- | :-- |"
+ ]
+
+ for pays in pays_tries:
+ for acteur in pays['acteurs']:
+ part_marche_formattee = acteur['part_marche'].strip('%') + ' %'
+ lignes_tableau.append(
+ f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
+ )
+ part_marche_pays_formattee = pays['part_marche'].strip('%') + ' %'
+ lignes_tableau.append(
+ f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
+ )
+
+ tableau_final = "\n".join(lignes_tableau)
+
+ if type_fiche == "fabrication":
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{tableau_final}\n",
+ md,
+ flags=re.DOTALL
+ )
+ else:
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{tableau_final}\n",
+ md,
+ flags=re.DOTALL
+ )
+ # Chercher et remplacer la section IHH si un schéma a été identifié
+ if schema:
+ # Charger le contenu de la fiche technique IHH
+ try:
+ # Essayer de lire le fichier depuis le système de fichiers
+ with open(FICHES_CRITICITE["IHH"], "r", encoding="utf-8") as f:
+ ihh_content = f.read()
+
+ # Chercher la section IHH correspondant au schéma et au type de fiche
+ # Format de la section : ## Assemblage/Fabrication - [Schema]
+ if type_fiche == "fabrication":
+ ihh_section_pattern = rf"## Fabrication - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
+ else: # type_fiche == "assemblage"
+ ihh_section_pattern = rf"## Assemblage - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
+ ihh_section_match = re.search(ihh_section_pattern, ihh_content)
+
+ if ihh_section_match:
+ # Extraire la section complète sans le titre principal
+ ihh_section = ihh_section_match.group(0).split("\n", 2)[2].strip()
+
+ # Remplacer la section IHH dans la fiche d'assemblage
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{ihh_section}\n",
+ md_modifie,
+ flags=re.DOTALL
+ )
+ else:
+ # Si aucune section IHH n'est trouvée pour ce schéma, laisser la section existante
+ 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}")
+
+ return md_modifie
diff --git a/utils/dynamic/indice/ics.py b/utils/dynamic/indice/ics.py
new file mode 100644
index 0000000..7771460
--- /dev/null
+++ b/utils/dynamic/indice/ics.py
@@ -0,0 +1,90 @@
+# ics.py
+
+import re
+import yaml
+import pandas as pd
+import unicodedata
+import textwrap
+
+PAIR_RE = re.compile(r"```yaml[^\n]*\n(.*?)```", re.S | re.I)
+
+def _normalize_unicode(text: str) -> str:
+ return unicodedata.normalize("NFKC", text)
+
+def _pairs_dataframe(md: str) -> pd.DataFrame:
+ rows = []
+ for raw in PAIR_RE.findall(md):
+ bloc = yaml.safe_load(raw)
+ if isinstance(bloc, dict) and "pair" in bloc:
+ rows.append(bloc["pair"])
+ return pd.DataFrame(rows)
+
+def _fill(segment: str, pair: dict) -> str:
+ segment = _normalize_unicode(segment)
+ for k, v in pair.items():
+ val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
+ segment = re.sub(
+ rf"{{{{\s*{re.escape(k)}\s*}}}}",
+ val,
+ segment,
+ flags=re.I,
+ )
+ segment = re.sub(
+ r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
+ f"ICS = {pair['ics']:.2f}",
+ segment,
+ count=1,
+ )
+ return segment
+
+def _segments(md: str):
+ blocs = list(PAIR_RE.finditer(md))
+ for i, match in enumerate(blocs):
+ pair = yaml.safe_load(match.group(1))["pair"]
+ start = match.end()
+ end = blocs[i + 1].start() if i + 1 < len(blocs) else len(md)
+ segment = md[start:end]
+ yield pair, segment
+
+def _pivot(df: pd.DataFrame) -> str:
+ out = []
+ for min_, g in df.groupby("minerai"):
+ out += [f"## {min_}",
+ "| Composant | ICS | Faisabilité technique | Délai d'implémentation | Impact économique |",
+ "| :-- | :--: | :--: | :--: | :--: |"]
+ for _, r in g.sort_values("ics", ascending=False).iterrows():
+ out += [f"| {r.composant} | {r.ics:.2f} | {r.f_tech:.2f} | "
+ f"{r.delai:.2f} | {r.cout:.2f} |"]
+ out.append("")
+ return "\n".join(out)
+
+def _synth(df: pd.DataFrame) -> str:
+ lignes = ["| Composant | Minerai | ICS |", "| :-- | :-- | :--: |"]
+ for _, r in df.sort_values("ics", ascending=False).iterrows():
+ lignes.append(f"| {r.composant} | {r.minerai} | {r.ics:.2f} |")
+ return "\n".join(lignes)
+
+def build_dynamic_sections(md_raw: str) -> str:
+ md_raw = _normalize_unicode(md_raw)
+ df = _pairs_dataframe(md_raw)
+ if df.empty:
+ return md_raw
+
+ couples = ["# Criticité par couple Composant -> Minerai"]
+ for pair, seg in _segments(md_raw):
+ if pair:
+ couples.append(_fill(seg, pair))
+ couples_md = "\n".join(couples)
+
+ pivot_md = _pivot(df)
+ synth_md = _synth(df)
+
+ md = re.sub(r"#\s+Criticité par couple.*", couples_md, md_raw, flags=re.S | re.I)
+ md = re.sub(r".*?",
+ f"\n{pivot_md}\n",
+ md, flags=re.S)
+ md = re.sub(r".*?",
+ f"\n{synth_md}\n",
+ md, flags=re.S)
+
+ return textwrap.dedent(md)
diff --git a/utils/dynamic/indice/ihh.py b/utils/dynamic/indice/ihh.py
new file mode 100644
index 0000000..5e5d654
--- /dev/null
+++ b/utils/dynamic/indice/ihh.py
@@ -0,0 +1,140 @@
+# ihh.py
+
+import re
+import yaml
+from jinja2 import Template
+from ..utils.pastille import pastille
+
+IHH_RE = re.compile(r"```yaml\s+opération:(.*?)```", re.S | re.I)
+
+def _synth_ihh(operations: list[dict]) -> str:
+ data_by_item = {}
+
+ for op in operations:
+ # nom = op.get('nom', '')
+ item_id = op.get('minerai', op.get('produit', op.get('composant', '')))
+ if not item_id:
+ continue
+
+ if item_id not in data_by_item:
+ data_by_item[item_id] = {
+ 'type': 'minerai' if 'extraction' in op or 'reserves' in op or 'traitement' in op else
+ 'produit' if 'assemblage' in op else 'composant',
+ 'extraction_ihh_pays': '-',
+ 'extraction_ihh_acteurs': '-',
+ 'reserves_ihh_pays': '-',
+ 'traitement_ihh_pays': '-',
+ 'traitement_ihh_acteurs': '-',
+ 'assemblage_ihh_pays': '-',
+ 'assemblage_ihh_acteurs': '-',
+ 'fabrication_ihh_pays': '-',
+ 'fabrication_ihh_acteurs': '-'
+ }
+
+ if 'extraction' in op:
+ data_by_item[item_id]['extraction_ihh_pays'] = op['extraction'].get('ihh_pays', '-')
+ data_by_item[item_id]['extraction_ihh_acteurs'] = op['extraction'].get('ihh_acteurs', '-')
+ data_by_item[item_id]['reserves_ihh_pays'] = op['reserves'].get('ihh_pays', '-')
+ data_by_item[item_id]['traitement_ihh_pays'] = op['traitement'].get('ihh_pays', '-')
+ data_by_item[item_id]['traitement_ihh_acteurs'] = op['traitement'].get('ihh_acteurs', '-')
+ elif 'assemblage' in op:
+ data_by_item[item_id]['assemblage_ihh_pays'] = op['assemblage'].get('ihh_pays', '-')
+ data_by_item[item_id]['assemblage_ihh_acteurs'] = op['assemblage'].get('ihh_acteurs', '-')
+ elif 'fabrication' in op:
+ data_by_item[item_id]['fabrication_ihh_pays'] = op['fabrication'].get('ihh_pays', '-')
+ data_by_item[item_id]['fabrication_ihh_acteurs'] = op['fabrication'].get('ihh_acteurs', '-')
+
+ result = []
+
+ produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
+ if produits:
+ result.append("\n\n## Assemblage des produits\n")
+ produit_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'])
+ produit_lignes.append(
+ f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
+ )
+ result.append("\n".join(produit_lignes))
+
+ composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
+ if composants:
+ result.append("\n\n## Fabrication des composants\n")
+ composant_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'])
+ composant_lignes.append(
+ f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
+ )
+ result.append("\n".join(composant_lignes))
+
+ minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
+ if minerais:
+ result.append("\n\n## Opérations sur les minerais\n")
+ minerai_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'])
+ pastille_3 = pastille("IHH", data['reserves_ihh_pays'])
+ pastille_4 = pastille("IHH", data['traitement_ihh_pays'])
+ pastille_5 = pastille("IHH", data['traitement_ihh_acteurs'])
+ minerai_lignes.append(
+ 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']} |"
+ )
+ result.append("\n".join(minerai_lignes))
+
+ return "\n".join(result)
+
+def build_ihh_sections(md: str) -> str:
+ segments = []
+ operations = []
+ intro = None
+
+ matches = list(IHH_RE.finditer(md))
+ if matches:
+ first = matches[0]
+ intro = md[:first.start()].strip()
+ else:
+ return md
+
+ for m in matches:
+ bloc_text = m.group(1)
+ bloc = yaml.safe_load("opération:" + bloc_text)
+ operations.append(bloc["opération"])
+
+ start = m.end()
+ next_match = IHH_RE.search(md, start)
+ end = next_match.start() if next_match else len(md)
+
+ section_template = md[start:end].strip()
+ rendered = Template(section_template).render(**bloc["opération"])
+ segments.append(rendered)
+
+ if intro:
+ segments.insert(0, intro)
+
+ if "# Tableaux de synthèse" in md:
+ synth_table = _synth_ihh(operations)
+ md_final = "\n\n".join(segments)
+ md_final = re.sub(
+ r"(?:##?|#) Tableaux de synthèse\s*\n.*?",
+ f"# Tableaux de synthèse\n\n{synth_table}\n",
+ md_final,
+ flags=re.S
+ )
+ else:
+ md_final = "\n\n".join(segments)
+
+ return md_final
diff --git a/utils/dynamic/indice/isg.py b/utils/dynamic/indice/isg.py
new file mode 100644
index 0000000..c2c2960
--- /dev/null
+++ b/utils/dynamic/indice/isg.py
@@ -0,0 +1,50 @@
+# isg.py
+
+import re
+import yaml
+from ..utils.pastille import pastille
+
+def _synth_isg(md: str) -> str:
+ yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
+ if not yaml_block:
+ return "*(aucune donnée de pays trouvée)*"
+
+ yaml_data = yaml.safe_load(yaml_block.group(1))
+ lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
+ sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
+
+ for identifiant, data in sorted_pays:
+ pays = data['pays']
+ wgi_ps = data['wgi_ps']
+ fsi = data['fsi']
+ ndgain = data['ndgain']
+ isg = data['isg']
+ pastille_isg = pastille("ISG", isg)
+ lignes.append(f"| {pays} | {wgi_ps} | {fsi} | {ndgain} | {pastille_isg} {isg} |")
+
+ return "\n".join(lignes)
+
+def build_isg_sections(md: str) -> str:
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ front_matter = yaml.safe_load(front_match.group(1))
+ if front_matter.get("indice_court") != "ISG":
+ return md
+
+ synth_table = _synth_isg(md)
+
+ md_final = re.sub(
+ r"## Tableau de synthèse\s*\n.*?",
+ f"## Tableau de synthèse\n\n{synth_table}\n",
+ md,
+ flags=re.S
+ )
+
+ md_final = re.sub(
+ r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
+ "# Criticité par pays\n\n",
+ md_final,
+ flags=re.S
+ )
+
+ return md_final
diff --git a/utils/dynamic/indice/ivc.py b/utils/dynamic/indice/ivc.py
new file mode 100644
index 0000000..5f9cb4a
--- /dev/null
+++ b/utils/dynamic/indice/ivc.py
@@ -0,0 +1,70 @@
+# ivc.py
+
+import re
+import yaml
+from jinja2 import Template
+
+IVC_RE = re.compile(r"```yaml\s+minerai:(.*?)```", re.S | re.I)
+
+def _synth_ivc(minerais: list[dict]) -> str:
+ """Crée un tableau de synthèse pour les IVC des minerais."""
+ lignes = [
+ "| Minerai | IVC | Vulnérabilité |",
+ "| :-- | :-- | :-- |"
+ ]
+ for minerai in minerais:
+ lignes.append(
+ f"| {minerai['nom']} | {minerai['ivc']} | {minerai['vulnerabilite']} |"
+ )
+ return "\n".join(lignes)
+
+def _ivc_segments(md: str):
+ """Yield (dict, segment) pour chaque bloc IVC yaml."""
+ pos = 0
+ for m in IVC_RE.finditer(md):
+ bloc = yaml.safe_load("minerai:" + m.group(1))
+ start = m.end()
+ next_match = IVC_RE.search(md, start)
+ end = next_match.start() if next_match else len(md)
+ yield bloc["minerai"], md[start:end].strip()
+ pos = end
+ 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."""
+ segments = []
+ minerais = [] # Pour collecter les données de chaque minerai
+ intro = None
+
+ matches = list(IVC_RE.finditer(md))
+ if matches:
+ first = matches[0]
+ intro = md[:first.start()].strip()
+ else:
+ return md # pas de blocs à traiter
+
+ for m in matches:
+ bloc = yaml.safe_load("minerai:" + m.group(1))
+ minerais.append(bloc["minerai"]) # Collecte les données
+ start = m.end()
+ next_match = IVC_RE.search(md, start)
+ end = next_match.start() if next_match else len(md)
+ rendered = Template(md[start:end].strip()).render(**bloc["minerai"])
+ segments.append(rendered)
+
+ if intro:
+ segments.insert(0, intro)
+
+ # Créer et insérer le tableau de synthèse
+ synth_table = _synth_ivc(minerais)
+ md_final = "\n\n".join(segments)
+
+ # Remplacer la section du tableau final
+ md_final = re.sub(
+ r"## Tableau de synthèse\s*\n.*?",
+ f"## Tableau de synthèse\n\n{synth_table}\n",
+ md_final,
+ flags=re.S
+ )
+
+ return md_final
diff --git a/utils/dynamic/minerai/minerai.py b/utils/dynamic/minerai/minerai.py
new file mode 100644
index 0000000..dafce38
--- /dev/null
+++ b/utils/dynamic/minerai/minerai.py
@@ -0,0 +1,581 @@
+import streamlit as st
+import re
+import yaml
+
+def _build_extraction_tableau(md: str, produit: str) -> str:
+ """Génère le tableau d'extraction pour les fiches de minerai."""
+ # Identifier la section d'extraction
+ extraction_pattern = rf"Extraction_{re.escape(produit)}"
+ extraction_match = re.search(f"{extraction_pattern}:", md)
+
+ if not extraction_match:
+ return md # Pas de section d'extraction trouvée
+
+ # Récupérer les données structurées
+ extraction_data = {}
+
+ # Rechercher tous les pays et leurs acteurs
+ pays_pattern = rf"(\w+)_{extraction_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
+ for pays_match in re.finditer(pays_pattern, md):
+ code_pays, nom_pays, part_pays = pays_match.groups()
+ try:
+ part_marche_num = float(part_pays.strip().rstrip('%'))
+ except ValueError:
+ part_marche_num = 0
+
+ extraction_data[code_pays] = {
+ 'nom': nom_pays.strip(),
+ 'part_marche': part_pays.strip(),
+ 'part_marche_num': part_marche_num,
+ 'acteurs': []
+ }
+
+ # Rechercher tous les acteurs pour ce pays
+ acteur_pattern = rf"(\w+)_{code_pays}_{extraction_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)\s+pays_d_origine:\s+([^\n]+)"
+ for acteur_match in re.finditer(acteur_pattern, md):
+ code_acteur, nom_acteur, part_acteur, pays_origine = acteur_match.groups()
+ try:
+ part_acteur_num = float(part_acteur.strip().rstrip('%'))
+ except ValueError:
+ part_acteur_num = 0
+
+ extraction_data[code_pays]['acteurs'].append({
+ 'nom': nom_acteur.strip(),
+ 'part_marche': part_acteur.strip(),
+ 'part_marche_num': part_acteur_num,
+ 'pays_origine': pays_origine.strip()
+ })
+
+ # Préparer les données pour l'affichage
+ pays_data = []
+ for code_pays, pays_info in extraction_data.items():
+ # Trier les acteurs par part de marché décroissante
+ acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
+
+ pays_data.append({
+ 'nom': pays_info['nom'],
+ 'part_marche': pays_info['part_marche'],
+ 'part_marche_num': pays_info['part_marche_num'],
+ 'acteurs': acteurs_tries
+ })
+
+ # Trier les pays par part de marché décroissante
+ pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
+
+ # Générer le tableau des producteurs
+ lignes_tableau = [
+ "| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
+ "| :-- | :-- | :-- | :-- |"
+ ]
+
+ for pays in pays_tries:
+ for acteur in pays['acteurs']:
+ # Formater la part de marché (ajouter un espace avant %)
+ part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
+ lignes_tableau.append(
+ f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
+ )
+
+ # Ajouter la ligne de total pour le pays (en gras)
+ part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
+ lignes_tableau.append(
+ f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
+ )
+
+ # Construire le tableau final
+ tableau_final = "\n".join(lignes_tableau)
+
+ # Remplacer la section du tableau dans la fiche
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{tableau_final}\n",
+ md,
+ flags=re.DOTALL
+ )
+
+ return md_modifie
+
+def _build_traitement_tableau(md: str, produit: str) -> str:
+ """Génère le tableau de traitement pour les fiches de minerai."""
+ # Identifier la section de traitement
+ traitement_pattern = rf"Traitement_{re.escape(produit)}"
+ traitement_match = re.search(f"{traitement_pattern}:", md)
+
+ if not traitement_match:
+ return md # Pas de section de traitement trouvée
+
+ # Récupérer les données structurées
+ traitement_data = {}
+
+ # Rechercher tous les pays et leurs acteurs
+ pays_pattern = rf"(\w+)_{traitement_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
+ for pays_match in re.finditer(pays_pattern, md):
+ code_pays, nom_pays, part_pays = pays_match.groups()
+ try:
+ part_marche_num = float(part_pays.strip().rstrip('%'))
+ except ValueError:
+ part_marche_num = 0
+
+ traitement_data[code_pays] = {
+ 'nom': nom_pays.strip(),
+ 'part_marche': part_pays.strip(),
+ 'part_marche_num': part_marche_num,
+ 'acteurs': []
+ }
+
+ # Rechercher tous les acteurs pour ce pays
+ acteur_pattern = rf"(\w+)_{code_pays}_{traitement_pattern}:"
+ for acteur_match in re.finditer(acteur_pattern, md):
+ code_acteur = acteur_match.group(1)
+
+ # Récupérer les informations de l'acteur
+ nom_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)", md)
+ part_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?part_de_marche:\s+([^\n]+)", md, re.DOTALL)
+ pays_origine_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?pays_d_origine:\s+([^\n]+)", md, re.DOTALL)
+
+ if nom_acteur_match and part_acteur_match and pays_origine_match:
+ nom_acteur = nom_acteur_match.group(1).strip()
+ part_acteur = part_acteur_match.group(1).strip()
+ pays_origine = pays_origine_match.group(1).strip()
+
+ try:
+ part_acteur_num = float(part_acteur.strip().rstrip('%'))
+ except ValueError:
+ part_acteur_num = 0
+
+ # Récupérer les origines du minerai
+ origines_minerai = []
+ for i in range(1, 10): # Vérifier jusqu'à 9 origines possibles
+ origine_key = "minerai_origine" if i == 1 else f"minerai_origine_{i}"
+ origine_pattern = rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?{origine_key}:\s+pays:\s+([^\n]+)\s+pourcentage:\s+([^\n]+)"
+ origine_match = re.search(origine_pattern, md, re.DOTALL)
+
+ if origine_match:
+ pays_origine_minerai = origine_match.group(1).strip()
+ pourcentage_origine = origine_match.group(2).strip()
+ origines_minerai.append(f"{pays_origine_minerai} ({pourcentage_origine})")
+ else:
+ break
+
+ origine_text = ", ".join(origines_minerai) if origines_minerai else ""
+
+ traitement_data[code_pays]['acteurs'].append({
+ 'nom': nom_acteur,
+ 'part_marche': part_acteur,
+ 'part_marche_num': part_acteur_num,
+ 'pays_origine': pays_origine,
+ 'origine_minerai': origine_text
+ })
+
+ # Préparer les données pour l'affichage
+ pays_data = []
+ for code_pays, pays_info in traitement_data.items():
+ # Trier les acteurs par part de marché décroissante
+ acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
+
+ pays_data.append({
+ 'nom': pays_info['nom'],
+ 'part_marche': pays_info['part_marche'],
+ 'part_marche_num': pays_info['part_marche_num'],
+ 'acteurs': acteurs_tries
+ })
+
+ # Trier les pays par part de marché décroissante
+ pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
+
+ # Générer le tableau des producteurs
+ lignes_tableau = [
+ "| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Origines du minerai** | **Part de marché** |",
+ "| :-- | :-- | :-- | :-- | :-- |"
+ ]
+
+ for pays in pays_tries:
+ for acteur in pays['acteurs']:
+ # Formater la part de marché (ajouter un espace avant %)
+ part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
+ lignes_tableau.append(
+ f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {acteur['origine_minerai']} | {part_marche_formattee} |"
+ )
+
+ # Ajouter la ligne de total pour le pays (en gras)
+ part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
+ lignes_tableau.append(
+ f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **-** | **{part_marche_pays_formattee}** |"
+ )
+
+ # Construire le tableau final
+ tableau_final = "\n".join(lignes_tableau)
+
+ # Remplacer la section du tableau dans la fiche
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{tableau_final}\n",
+ md,
+ flags=re.DOTALL
+ )
+
+ return md_modifie
+
+def _build_reserves_tableau(md: str, produit: str) -> str:
+ """Génère le tableau des réserves pour les fiches de minerai."""
+ # Identifier la section des réserves
+ reserves_pattern = rf"Reserves_{re.escape(produit)}"
+ reserves_match = re.search(f"{reserves_pattern}:", md)
+
+ if not reserves_match:
+ return md # Pas de section de réserves trouvée
+
+ # Récupérer les données structurées
+ reserves_data = []
+
+ # Rechercher tous les pays et leurs parts de marché
+ pays_pattern = rf"(\w+)_{reserves_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
+ for pays_match in re.finditer(pays_pattern, md):
+ code_pays, nom_pays, part_pays = pays_match.groups()
+ try:
+ part_marche_num = float(part_pays.strip().rstrip('%'))
+ except ValueError:
+ part_marche_num = 0
+
+ reserves_data.append({
+ 'nom': nom_pays.strip(),
+ 'part_marche': part_pays.strip(),
+ 'part_marche_num': part_marche_num
+ })
+
+ # Trier les pays par part de marché décroissante
+ reserves_data_triees = sorted(reserves_data, key=lambda x: x['part_marche_num'], reverse=True)
+
+ # Générer le tableau des réserves
+ lignes_tableau = [
+ "| **Pays d'implantation** | **Part de marché** |",
+ "| :-- | :-- |"
+ ]
+
+ for pays in reserves_data_triees:
+ # Formater la part de marché (ajouter un espace avant %)
+ part_marche_formattee = pays['part_marche'].strip().replace('%', ' %')
+ lignes_tableau.append(f"| {pays['nom']} | {part_marche_formattee} |")
+
+ # Construire le tableau final
+ tableau_final = "\n".join(lignes_tableau)
+
+ # Remplacer la section du tableau dans la fiche
+ md_modifie = re.sub(
+ r".*?",
+ f"\n{tableau_final}\n",
+ md,
+ flags=re.DOTALL
+ )
+
+ return md_modifie
+
+def build_minerai_ivc_section(md: str) -> str:
+ """
+ Ajoute les informations IVC depuis la fiche technique IVC.md pour un minerai spécifique.
+ """
+ # Extraire le type de fiche et le produit depuis l'en-tête YAML
+ type_fiche = None
+ produit = None
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ try:
+ front_matter = yaml.safe_load(front_match.group(1))
+ type_fiche = front_matter.get("type_fiche")
+ produit = front_matter.get("produit")
+
+ # Vérifier si c'est bien une fiche de minerai
+ if type_fiche != "minerai" or not produit:
+ return md
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du front matter: {e}")
+ return md
+
+ # Injecter les informations IVC depuis la fiche technique
+ try:
+ # Charger le contenu de la fiche technique IVC
+ ivc_path = "Fiches/Criticités/Fiche technique IVC.md"
+ with open(ivc_path, "r", encoding="utf-8") as f:
+ ivc_content = f.read()
+
+ # Chercher la section correspondant au minerai
+ # Le pattern cherche un titre de niveau 2 commençant par le nom du produit
+ section_pattern = rf"## {produit} - (.*?)(?=\n###|$)"
+ section_match = re.search(section_pattern, ivc_content, re.DOTALL)
+
+ if section_match:
+ # Extraire la partie après le nom du minerai (ex: "IVC : 4 - Vulnérabilité: Faible")
+ ivc_value = section_match.group(1).strip()
+
+ # Extraire toutes les sous-sections
+ # Le pattern cherche depuis le titre du minerai jusqu'à la prochaine section de même niveau ou fin de fichier
+ full_section_pattern = rf"## {produit} - .*?\n([\s\S]*?)(?=\n## |$)"
+ full_section_match = re.search(full_section_pattern, ivc_content)
+
+ if full_section_match:
+ section_content = full_section_match.group(1).strip()
+
+ # Formater le contenu à insérer
+ ivc_content_formatted = f"```\n{ivc_value}\n```\n\n{section_content}"
+
+ # Remplacer la section IVC dans le markdown
+ md = re.sub(
+ r".*?",
+ f"\n{ivc_content_formatted}\n",
+ md,
+ flags=re.DOTALL
+ )
+ except Exception as e:
+ st.error(f"Erreur lors de la génération de la section IVC: {e}")
+
+ return md
+
+def build_minerai_ics_section(md: str) -> str:
+ """
+ Ajoute les informations ICS depuis la fiche technique ICS.md pour un minerai spécifique.
+ """
+ # Extraire le type de fiche et le produit depuis l'en-tête YAML
+ type_fiche = None
+ produit = None
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ try:
+ front_matter = yaml.safe_load(front_match.group(1))
+ type_fiche = front_matter.get("type_fiche")
+ produit = front_matter.get("produit")
+
+ # Vérifier si c'est bien une fiche de minerai
+ if type_fiche != "minerai" or not produit:
+ return md
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du front matter: {e}")
+ return md
+
+ # Injecter les informations ICS depuis la fiche technique
+ try:
+ # Charger le contenu de la fiche technique ICS
+ ics_path = "Fiches/Criticités/Fiche technique ICS.md"
+ with open(ics_path, "r", encoding="utf-8") as f:
+ ics_content = f.read()
+
+ # Extraire la section ICS pour le minerai
+ # Dans le fichier ICS, on recherche dans la section "# Criticité par minerai"
+ # puis on cherche la sous-section correspondant au minerai
+ minerai_section_pattern = r"# Criticité par minerai\s*\n([\s\S]*?)(?=\n# |$)"
+ minerai_section_match = re.search(minerai_section_pattern, ics_content)
+
+ if minerai_section_match:
+ minerai_section = minerai_section_match.group(1)
+
+ # Chercher le minerai spécifique
+ # Rechercher un titre de niveau 2 correspondant exactement au produit
+ specific_minerai_pattern = rf"## {produit}\s*\n([\s\S]*?)(?=\n## |$)"
+ specific_minerai_match = re.search(specific_minerai_pattern, minerai_section)
+
+ if specific_minerai_match:
+ # Extraire le contenu de la section du minerai
+ minerai_content = specific_minerai_match.group(1).strip()
+
+ # Remplacer la section ICS dans le markdown
+ md = re.sub(
+ r".*?",
+ f"\n{minerai_content}\n",
+ md,
+ flags=re.DOTALL
+ )
+ except Exception as e:
+ st.error(f"Erreur lors de la génération de la section ICS: {e}")
+
+ return md
+
+def build_minerai_ics_composant_section(md: str) -> str:
+ """
+ Ajoute les informations ICS pour tous les composants liés à un minerai spécifique
+ depuis la fiche technique ICS.md, en augmentant d'un niveau les titres.
+ """
+ # Extraire le type de fiche et le produit depuis l'en-tête YAML
+ type_fiche = None
+ produit = None
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ try:
+ front_matter = yaml.safe_load(front_match.group(1))
+ type_fiche = front_matter.get("type_fiche")
+ produit = front_matter.get("produit")
+
+ # Vérifier si c'est bien une fiche de minerai
+ if type_fiche != "minerai" or not produit:
+ return md
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du front matter: {e}")
+ return md
+
+ # Injecter les informations ICS depuis la fiche technique
+ try:
+ # Charger le contenu de la fiche technique ICS
+ ics_path = "Fiches/Criticités/Fiche technique ICS.md"
+ with open(ics_path, "r", encoding="utf-8") as f:
+ ics_content = f.read()
+
+ # Rechercher toutes les sections de composants liés au minerai
+ # Le pattern cherche les titres de niveau 2 de la forme "## * -> Minerai"
+ composant_sections_pattern = rf"## ([^>]+) -> {produit} - .*?\n([\s\S]*?)(?=\n## |$)"
+ composant_sections = re.finditer(composant_sections_pattern, ics_content)
+
+ all_composant_content = []
+
+ for match in composant_sections:
+ composant = match.group(1).strip()
+ section_content = match.group(2).strip()
+
+ # Augmenter le niveau des titres d'un cran
+ # Titre de niveau 2 -> niveau 3
+ section_title = f"### {composant} -> {produit}{match.group(0).split(produit)[1].split('\n')[0]}"
+
+ # Augmenter les niveaux des sous-titres (### -> ####)
+ section_content = re.sub(r"### ", "#### ", section_content)
+
+ # Ajouter à la liste des contenus
+ all_composant_content.append(f"{section_title}\n{section_content}")
+
+ # Combiner tous les contenus
+ if all_composant_content:
+ combined_content = "\n\n".join(all_composant_content)
+
+ # Remplacer la section ICS dans le markdown
+ md = re.sub(
+ r".*?",
+ f"\n{combined_content}\n",
+ md,
+ flags=re.DOTALL
+ )
+ except Exception as e:
+ st.error(f"Erreur lors de la génération de la section ICS pour les composants: {e}")
+
+ 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
+ type_fiche = None
+ produit = None
+ front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
+ if front_match:
+ try:
+ front_matter = yaml.safe_load(front_match.group(1))
+ type_fiche = front_matter.get("type_fiche")
+ produit = front_matter.get("produit")
+
+ # Vérifier si c'est bien une fiche de minerai
+ if type_fiche != "minerai" or not produit:
+ return md
+ except Exception as e:
+ st.error(f"Erreur lors du chargement du front matter: {e}")
+ return md
+
+ # Traiter le tableau d'Extraction
+ md = _build_extraction_tableau(md, produit)
+
+ # Traiter le tableau de Traitement
+ md = _build_traitement_tableau(md, produit)
+
+ # Traiter le tableau des Réserves
+ md = _build_reserves_tableau(md, produit)
+
+ # Supprimer les blocs YAML complets, y compris les délimiteurs ```yaml et ```
+ # Rechercher d'abord les blocs avec délimiteurs YAML
+ yaml_blocks = [
+ rf"```yaml\s*\n{re.escape(produit)}.*?```",
+ rf"```yaml\s*\nExtraction_{re.escape(produit)}.*?```",
+ rf"```yaml\s*\nReserves_{re.escape(produit)}.*?```",
+ rf"```yaml\s*\nTraitement_{re.escape(produit)}.*?```"
+ ]
+
+ for pattern in yaml_blocks:
+ md = re.sub(pattern, "", md, flags=re.DOTALL)
+
+ # Supprimer également les blocs qui ne seraient pas entourés de délimiteurs
+ patterns_to_remove = [
+ rf"Extraction_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
+ rf"Reserves_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
+ rf"Traitement_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)"
+ ]
+
+ for pattern in patterns_to_remove:
+ md = re.sub(pattern, "", md, flags=re.DOTALL)
+
+ # Nettoyer les délimiteurs ```yaml et ``` qui pourraient rester
+ md = re.sub(r"```yaml\s*\n", "", md)
+ md = re.sub(r"```\s*\n", "", md)
+
+ # Injecter les sections IHH depuis la fiche technique
+ try:
+ # Charger le contenu de la fiche technique IHH
+ ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
+ with open(ihh_path, "r", encoding="utf-8") as f:
+ ihh_content = f.read()
+
+ # D'abord, extraire toute la section concernant le produit
+ section_produit_pattern = rf"## Opérations - {produit}\s*\n([\s\S]*?)(?=\n## |$)"
+ section_produit_match = re.search(section_produit_pattern, ihh_content, re.IGNORECASE)
+
+ if section_produit_match:
+ section_produit = section_produit_match.group(1).strip()
+
+ # Maintenant, extraire les sous-sections individuelles à partir de la section du produit
+
+ # 1. Extraction - incluant le titre de niveau 3
+ extraction_pattern = r"(### Indice de Herfindahl-Hirschmann - Extraction\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Réserves|$)"
+ extraction_match = re.search(extraction_pattern, section_produit, re.IGNORECASE)
+
+ if extraction_match:
+ extraction_ihh = extraction_match.group(1).strip()
+ md = re.sub(
+ r".*?",
+ f"\n{extraction_ihh}\n",
+ md,
+ flags=re.DOTALL
+ )
+
+ # 2. Réserves - incluant le titre de niveau 3
+ reserves_pattern = r"(### Indice de Herfindahl-Hirschmann - Réserves\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Traitement|$)"
+ reserves_match = re.search(reserves_pattern, section_produit, re.IGNORECASE)
+
+ if reserves_match:
+ reserves_ihh = reserves_match.group(1).strip()
+ md = re.sub(
+ r".*?",
+ f"\n{reserves_ihh}\n",
+ md,
+ flags=re.DOTALL
+ )
+
+ # 3. Traitement - incluant le titre de niveau 3
+ traitement_pattern = r"(### Indice de Herfindahl-Hirschmann - Traitement\s*\n[\s\S]*?)(?=$)"
+ traitement_match = re.search(traitement_pattern, section_produit, re.IGNORECASE)
+
+ if traitement_match:
+ traitement_ihh = traitement_match.group(1).strip()
+ md = re.sub(
+ r".*?",
+ f"\n{traitement_ihh}\n",
+ md,
+ flags=re.DOTALL
+ )
+ except Exception as e:
+ st.error(f"Erreur lors de la génération des sections IHH: {e}")
+
+ # Nettoyer les doubles sauts de ligne
+ md = re.sub(r"\n{3,}", "\n\n", md)
+
+ # Ajouter les informations IVC
+ md = build_minerai_ivc_section(md)
+
+ # Ajouter les informations ICS
+ md = build_minerai_ics_section(md)
+
+ # Ajouter les informations ICS pour les composants liés au minerai
+ md = build_minerai_ics_composant_section(md)
+
+ return md
diff --git a/utils/dynamic/utils/pastille.py b/utils/dynamic/utils/pastille.py
new file mode 100644
index 0000000..1a50811
--- /dev/null
+++ b/utils/dynamic/utils/pastille.py
@@ -0,0 +1,30 @@
+# pastille.py
+
+from typing import Any
+
+PASTILLE_ICONS = {
+ "vert": "✅",
+ "orange": "🔶",
+ "rouge": "🔴"
+}
+
+def pastille(indice: str, valeur: Any, seuils: dict = None) -> str:
+ try:
+ import streamlit as st
+ seuils = seuils or st.session_state.get("seuils", {})
+ if indice not in seuils:
+ return ""
+
+ seuil = seuils[indice]
+ vert_max = seuil["vert"]["max"]
+ rouge_min = seuil["rouge"]["min"]
+ val = float(valeur)
+
+ if val < vert_max:
+ return PASTILLE_ICONS["vert"]
+ elif val > rouge_min:
+ return PASTILLE_ICONS["rouge"]
+ else:
+ return PASTILLE_ICONS["orange"]
+ except (KeyError, ValueError, TypeError):
+ return ""
diff --git a/utils/fiche_dynamic.py b/utils/fiche_dynamic.py
deleted file mode 100644
index 36f42d6..0000000
--- a/utils/fiche_dynamic.py
+++ /dev/null
@@ -1,552 +0,0 @@
-import re, yaml, pandas as pd, textwrap
-import unicodedata
-from jinja2 import Template
-import streamlit as st
-
-
-def pastille(indice, valeur):
- try:
- SEUILS = st.session_state['seuils']
- VERT = SEUILS[indice]["vert"]["max"]
- ROUGE = SEUILS[indice]["rouge"]["min"]
- pastille_verte = "✅"
- pastille_orange = "🔶"
- pastille_rouge = "🔴"
- if float(valeur) < VERT:
- return pastille_verte
- elif float(valeur) > ROUGE:
- return pastille_rouge
- else:
- return pastille_orange
- except:
- return ""
-
-# -------- repère chaque bloc ```yaml … ``` -------------
-PAIR_RE = re.compile(r"```yaml[^\n]*\n(.*?)```", re.S | re.I)
-
-def _pairs_dataframe(md: str) -> pd.DataFrame:
- rows = []
- for raw in PAIR_RE.findall(md):
- bloc = yaml.safe_load(raw)
- if isinstance(bloc, dict) and "pair" in bloc:
- rows.append(bloc["pair"])
- return pd.DataFrame(rows)
-
-def _normalize_unicode(text: str) -> str:
- return unicodedata.normalize("NFKC", text)
-
-def _fill(segment: str, pair: dict) -> str:
- segment = _normalize_unicode(segment)
- for k, v in pair.items():
- val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
- segment = re.sub(
- rf"{{{{\s*{re.escape(k)}\s*}}}}",
- val,
- segment,
- flags=re.I,
- )
- segment = re.sub(
- r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
- f"ICS = {pair['ics']:.2f}",
- segment,
- count=1,
- )
- return segment
-
-def _segments(md: str):
- blocs = list(PAIR_RE.finditer(md))
- for i, match in enumerate(blocs):
- pair = yaml.safe_load(match.group(1))["pair"]
- start = match.end()
- end = blocs[i + 1].start() if i + 1 < len(blocs) else len(md)
- segment = md[start:end]
- yield pair, segment
-
-def _pivot(df: pd.DataFrame) -> str:
- out = []
- for min_, g in df.groupby("minerai"):
- out += [f"## {min_}",
- "| Composant | ICS | Faisabilité technique | Délai d'implémentation | Impact économique |",
- "| :-- | :--: | :--: | :--: | :--: |"]
- for _, r in g.sort_values("ics", ascending=False).iterrows():
- out += [f"| {r.composant} | {r.ics:.2f} | {r.f_tech:.2f} | "
- f"{r.delai:.2f} | {r.cout:.2f} |"]
- out.append("")
- return "\n".join(out)
-
-def _synth(df: pd.DataFrame) -> str:
- lignes = ["| Composant | Minerai | ICS |", "| :-- | :-- | :--: |"]
- for _, r in df.sort_values("ics", ascending=False).iterrows():
- lignes.append(f"| {r.composant} | {r.minerai} | {r.ics:.2f} |")
- return "\n".join(lignes)
-
-def build_dynamic_sections(md_raw: str) -> str:
- md_raw = _normalize_unicode(md_raw)
- df = _pairs_dataframe(md_raw)
- if df.empty:
- return md_raw
-
- couples = ["# Criticité par couple Composant -> Minerai"]
- for pair, seg in _segments(md_raw):
- if pair:
- couples.append(_fill(seg, pair))
- couples_md = "\n".join(couples)
-
- pivot_md = _pivot(df)
- synth_md = _synth(df)
-
- md = re.sub(r"#\s+Criticité par couple.*", couples_md, md_raw, flags=re.S | re.I)
- md = re.sub(r".*?",
- f"\n{pivot_md}\n",
- md, flags=re.S)
- md = re.sub(r".*?",
- f"\n{synth_md}\n",
- md, flags=re.S)
-
- return textwrap.dedent(md)
-
-
-IVC_RE = re.compile(r"```yaml\s+minerai:(.*?)```", re.S | re.I)
-
-
-def _synth_ivc(minerais: list[dict]) -> str:
- """Crée un tableau de synthèse pour les IVC des minerais."""
- lignes = [
- "| Minerai | IVC | Vulnérabilité |",
- "| :-- | :-- | :-- |"
- ]
- for minerai in minerais:
- lignes.append(
- f"| {minerai['nom']} | {minerai['ivc']} | {minerai['vulnerabilite']} |"
- )
- return "\n".join(lignes)
-
-def _ivc_segments(md: str):
- """Yield (dict, segment) pour chaque bloc IVC yaml."""
- pos = 0
- for m in IVC_RE.finditer(md):
- bloc = yaml.safe_load("minerai:" + m.group(1))
- start = m.end()
- next_match = IVC_RE.search(md, start)
- end = next_match.start() if next_match else len(md)
- yield bloc["minerai"], md[start:end].strip()
- pos = end
- 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."""
- segments = []
- minerais = [] # Pour collecter les données de chaque minerai
- intro = None
-
- matches = list(IVC_RE.finditer(md))
- if matches:
- first = matches[0]
- intro = md[:first.start()].strip()
- else:
- return md # pas de blocs à traiter
-
- for m in matches:
- bloc = yaml.safe_load("minerai:" + m.group(1))
- minerais.append(bloc["minerai"]) # Collecte les données
- start = m.end()
- next_match = IVC_RE.search(md, start)
- end = next_match.start() if next_match else len(md)
- rendered = Template(md[start:end].strip()).render(**bloc["minerai"])
- segments.append(rendered)
-
- if intro:
- segments.insert(0, intro)
-
- # Créer et insérer le tableau de synthèse
- synth_table = _synth_ivc(minerais)
- md_final = "\n\n".join(segments)
-
- # Remplacer la section du tableau final
- md_final = re.sub(
- r"## Tableau de synthèse\s*\n.*?",
- f"## Tableau de synthèse\n\n{synth_table}\n",
- md_final,
- flags=re.S
- )
-
- return md_final
-
-
-def build_assemblage_sections(md: str) -> str:
- """Traite les fiches d'assemblage/fabrication et génère le tableau des assembleurs/fabricants à partir du bloc YAML."""
- # Extraire le schéma depuis l'en-tête YAML de la fiche
- schema = None
- front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
- if front_match:
- try:
- front_matter = yaml.safe_load(front_match.group(1))
- schema = front_matter.get("schema")
- type_fiche = front_matter.get("type_fiche")
-
- # Vérifier si c'est bien une fiche d'assemblage ou de fabrication
- if type_fiche not in ["assemblage", "fabrication"] or not schema:
- return md
- except Exception as e:
- st.error(f"Erreur lors du chargement du front matter: {e}")
- return md
-
- # Rechercher le bloc YAML dans la fiche (entre ```yaml et ```)
- yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
-
- if not yaml_block:
- return md # Pas de bloc YAML trouvé, retourner la fiche inchangée
-
- # Charger les données YAML
- try:
- yaml_data = yaml.safe_load(yaml_block.group(1))
- except Exception as e:
- st.error(f"Erreur lors du chargement du YAML: {e}")
- return md
-
- # Vérifier la structure du YAML (clé principale suivie des pays)
- if not isinstance(yaml_data, dict) or len(yaml_data) == 0:
- return md
-
- # Récupérer la clé principale (nom du produit assemblé)
- produit_key = list(yaml_data.keys())[0]
- produit_data = yaml_data[produit_key]
-
- # Structure pour stocker les données triées
- pays_data = []
-
- # Pour chaque pays, extraire ses données et celles de ses acteurs
- for pays_key, pays_info in produit_data.items():
- nom_pays = pays_info.get('nom_du_pays', '')
- part_marche_pays = pays_info.get('part_de_marche', '0%')
-
- # Convertir la part de marché en nombre pour le tri
- part_marche_num = float(part_marche_pays.strip('%'))
-
- acteurs = []
- for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
- nom_acteur = acteur_info.get('nom_de_l_acteur', '')
- part_marche_acteur = acteur_info.get('part_de_marche', '0%')
- pays_origine = acteur_info.get('pays_d_origine', '')
-
- # Convertir la part de marché en nombre pour le tri
- part_marche_acteur_num = float(part_marche_acteur.strip('%'))
-
- acteurs.append({
- 'nom': nom_acteur,
- 'part_marche': part_marche_acteur,
- 'part_marche_num': part_marche_acteur_num,
- 'pays_origine': pays_origine
- })
-
- # Trier les acteurs par part de marché décroissante
- acteurs_tries = sorted(acteurs, key=lambda x: x['part_marche_num'], reverse=True)
-
- pays_data.append({
- 'nom': nom_pays,
- 'part_marche': part_marche_pays,
- 'part_marche_num': part_marche_num,
- 'acteurs': acteurs_tries
- })
-
- # Trier les pays par part de marché décroissante
- pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
-
- # Générer le tableau des assembleurs
- lignes_tableau = [
- "| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
- "| :-- | :-- | :-- | :-- |"
- ]
-
- for pays in pays_tries:
- for acteur in pays['acteurs']:
- # Formater la part de marché (retirer le % du texte et ajouter un espace avant %)
- part_marche_formattee = acteur['part_marche'].strip('%') + ' %'
- lignes_tableau.append(
- f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
- )
-
- # Ajouter la ligne de total pour le pays (en gras)
- part_marche_pays_formattee = pays['part_marche'].strip('%') + ' %'
- lignes_tableau.append(
- f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
- )
-
- # Construire le tableau final
- tableau_final = "\n".join(lignes_tableau)
-
- # Remplacer la section du tableau dans la fiche (assembleurs ou fabricants selon le type)
- if type_fiche == "fabrication":
- md_modifie = re.sub(
- r".*?",
- f"\n{tableau_final}\n",
- md,
- flags=re.DOTALL
- )
- else: # type_fiche == "assemblage"
- md_modifie = re.sub(
- r".*?",
- f"\n{tableau_final}\n",
- md,
- flags=re.DOTALL
- )
-
- # Chercher et remplacer la section IHH si un schéma a été identifié
- if schema:
- # Charger le contenu de la fiche technique IHH
- try:
- # Essayer de lire le fichier depuis le système de fichiers
- ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
- with open(ihh_path, "r", encoding="utf-8") as f:
- ihh_content = f.read()
-
- # Chercher la section IHH correspondant au schéma et au type de fiche
- # Format de la section : ## Assemblage/Fabrication - [Schema]
- if type_fiche == "fabrication":
- ihh_section_pattern = rf"## Fabrication - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
- else: # type_fiche == "assemblage"
- ihh_section_pattern = rf"## Assemblage - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
- ihh_section_match = re.search(ihh_section_pattern, ihh_content)
-
- if ihh_section_match:
- # Extraire la section complète sans le titre principal
- ihh_section = ihh_section_match.group(0).split("\n", 2)[2].strip()
-
- # Remplacer la section IHH dans la fiche d'assemblage
- md_modifie = re.sub(
- r".*?",
- f"\n{ihh_section}\n",
- md_modifie,
- flags=re.DOTALL
- )
- else:
- # Si aucune section IHH n'est trouvée pour ce schéma, laisser la section existante
- 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}")
-
- return md_modifie
-
-
-# Regex pour capturer les blocs YAML d'opération dans les fiches IHH
-IHH_RE = re.compile(r"```yaml\s+opération:(.*?)```", re.S | re.I)
-
-
-def _synth_ihh(operations: list[dict]) -> str:
- """Crée un tableau de synthèse pour les IHH."""
- # Créer un dictionnaire pour regrouper les données par minerai/produit/composant
- data_by_item = {}
-
- for op in operations:
- nom = op.get('nom', '')
- item_id = op.get('minerai', op.get('produit', op.get('composant', '')))
-
- if not item_id:
- continue
-
- # Initialiser l'entrée si elle n'existe pas encore
- if item_id not in data_by_item:
- data_by_item[item_id] = {
- 'type': 'minerai' if 'extraction' in op or 'reserves' in op or 'traitement' in op else
- 'produit' if 'assemblage' in op else 'composant',
- 'extraction_ihh_pays': '-',
- 'extraction_ihh_acteurs': '-',
- 'reserves_ihh_pays': '-',
- 'traitement_ihh_pays': '-',
- 'traitement_ihh_acteurs': '-',
- 'assemblage_ihh_pays': '-',
- 'assemblage_ihh_acteurs': '-',
- 'fabrication_ihh_pays': '-',
- 'fabrication_ihh_acteurs': '-'
- }
-
- # Mettre à jour les valeurs selon le type d'opération
- if 'extraction' in op:
- data_by_item[item_id]['extraction_ihh_pays'] = op['extraction'].get('ihh_pays', '-')
- data_by_item[item_id]['extraction_ihh_acteurs'] = op['extraction'].get('ihh_acteurs', '-')
- data_by_item[item_id]['reserves_ihh_pays'] = op['reserves'].get('ihh_pays', '-')
- data_by_item[item_id]['traitement_ihh_pays'] = op['traitement'].get('ihh_pays', '-')
- data_by_item[item_id]['traitement_ihh_acteurs'] = op['traitement'].get('ihh_acteurs', '-')
- elif 'assemblage' in op:
- data_by_item[item_id]['assemblage_ihh_pays'] = op['assemblage'].get('ihh_pays', '-')
- data_by_item[item_id]['assemblage_ihh_acteurs'] = op['assemblage'].get('ihh_acteurs', '-')
- elif 'fabrication' in op:
- data_by_item[item_id]['fabrication_ihh_pays'] = op['fabrication'].get('ihh_pays', '-')
- data_by_item[item_id]['fabrication_ihh_acteurs'] = op['fabrication'].get('ihh_acteurs', '-')
-
- # Compléter avec les autres types si présents
- result = []
-
- # Tableau des produits
- produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
- if produits:
- result.append("\n\n## Assemblage des produits\n")
- produit_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'])
- produit_lignes.append(
- f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
- )
-
- result.append("\n".join(produit_lignes))
-
- # Tableau des composants
- composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
- if composants:
- result.append("\n\n## Fabrication des composants\n")
- composant_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'])
- composant_lignes.append(
- f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
- )
-
- result.append("\n".join(composant_lignes))
-
- # Trier et créer le tableau de minerais (celui demandé)
- minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
- if minerais:
- result.append("\n\n## Opérations sur les minerais\n")
- minerai_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'])
- pastille_3 = pastille("IHH", data['reserves_ihh_pays'])
- pastille_4 = pastille("IHH", data['traitement_ihh_pays'])
- pastille_5 = pastille("IHH", data['traitement_ihh_acteurs'])
- minerai_lignes.append(
- 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']} |"
- )
-
- result.append("\n".join(minerai_lignes))
-
-
- return "\n".join(result)
-
-
-def build_ihh_sections(md: str) -> str:
- """Traite les fiches IHH pour les opérations, produits, composants et minerais."""
- segments = []
- operations = [] # Pour collecter les données de chaque opération
- intro = None
-
- matches = list(IHH_RE.finditer(md))
- if matches:
- first = matches[0]
- intro = md[:first.start()].strip()
- else:
- return md # pas de blocs à traiter
-
- # Traiter chaque bloc YAML et sa section correspondante
- for m in matches:
- bloc_text = m.group(1)
- bloc = yaml.safe_load("opération:" + bloc_text)
- operations.append(bloc["opération"]) # Collecte les données
-
- start = m.end()
- next_match = IHH_RE.search(md, start)
- end = next_match.start() if next_match else len(md)
-
- # Utiliser Jinja2 pour le rendu de la section
- section_template = md[start:end].strip()
- rendered = Template(section_template).render(**bloc["opération"])
- segments.append(rendered)
-
- if intro:
- segments.insert(0, intro)
-
- # Créer et insérer le tableau de synthèse si nécessaire
- if "# Tableaux de synthèse" in md:
- synth_table = _synth_ihh(operations)
- md_final = "\n\n".join(segments)
-
- # Remplacer la section du tableau final
- md_final = re.sub(
- r"(?:##?|#) Tableaux de synthèse\s*\n.*?",
- f"# Tableaux de synthèse\n\n{synth_table}\n",
- md_final,
- flags=re.S
- )
- else:
- md_final = "\n\n".join(segments)
-
- return md_final
-
-
-def _synth_isg(md: str) -> str:
- """Crée un tableau de synthèse pour les ISG à partir du bloc YAML des pays."""
- # Regex pour trouver le bloc YAML des pays
- yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
-
- if not yaml_block:
- return "*(aucune donnée de pays trouvée)*"
-
- # Charger les données YAML
- yaml_data = yaml.safe_load(yaml_block.group(1))
-
- # Préparer les lignes du tableau
- lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
-
- # Trier les pays par nom
- sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
-
- # Ajouter chaque pays au tableau
- for identifiant, data in sorted_pays:
- pays = data['pays']
- wgi_ps = data['wgi_ps']
- fsi = data['fsi']
- ndgain = data['ndgain']
- isg = data['isg']
-
- # Ajouter pastilles en fonction des seuils
- pastille_isg = pastille("ISG", isg) if 'ISG' in st.session_state.get('seuils', {}) else ""
-
- lignes.append(f"| {pays} | {wgi_ps} | {fsi} | {ndgain} | {pastille_isg} {isg} |")
-
- return "\n".join(lignes)
-
-def build_isg_sections(md: str) -> str:
- """Traite les fiches ISG pour générer le tableau de synthèse des pays."""
- # Vérifier si c'est une fiche ISG (via indice_court dans les frontmatter)
- front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
- if front_match:
- front_matter = yaml.safe_load(front_match.group(1))
- if front_matter.get("indice_court") != "ISG":
- return md # Ce n'est pas une fiche ISG
-
- # Générer le tableau de synthèse
- synth_table = _synth_isg(md)
-
- # Remplacer la section du tableau final
- md_final = re.sub(
- r"## Tableau de synthèse\s*\n.*?",
- f"## Tableau de synthèse\n\n{synth_table}\n",
- md,
- flags=re.S
- )
-
- # Supprimer le bloc YAML des pays (entre # Criticité par pays et le prochain titre ##)
- md_final = re.sub(
- r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
- "# Criticité par pays\n\n",
- md_final,
- flags=re.S
- )
-
- return md_final
diff --git a/utils/tickets/__init__.py b/utils/tickets/__init__.py
new file mode 100644
index 0000000..6a3be46
--- /dev/null
+++ b/utils/tickets/__init__.py
@@ -0,0 +1,22 @@
+# __init__.py du répertoire tickets
+
+from .core import (
+ rechercher_tickets_gitea,
+ charger_fiches_et_labels,
+ get_labels_existants,
+ gitea_request,
+ construire_corps_ticket_markdown,
+ creer_ticket_gitea,
+ nettoyer_labels
+)
+
+from .display import (
+ afficher_tickets_par_fiche,
+ afficher_carte_ticket,
+ recuperer_commentaires_ticket
+)
+
+from .creation import (
+ formulaire_creation_ticket_dynamique,
+ charger_modele_ticket
+)
diff --git a/utils/tickets/core.py b/utils/tickets/core.py
new file mode 100644
index 0000000..8d4a9d3
--- /dev/null
+++ b/utils/tickets/core.py
@@ -0,0 +1,118 @@
+# core.py
+
+import csv
+import json
+import requests
+import os
+import streamlit as st
+from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, ENV
+
+
+def gitea_request(method, url, **kwargs):
+ headers = kwargs.pop("headers", {})
+ headers["Authorization"] = f"token {GITEA_TOKEN}"
+ try:
+ response = requests.request(method, url, headers=headers, timeout=10, **kwargs)
+ response.raise_for_status()
+ return response
+ except requests.RequestException as e:
+ st.error(f"Erreur Gitea ({method.upper()}): {e}")
+ return None
+
+
+def charger_fiches_et_labels():
+ chemin_csv = os.path.join("assets", "fiches_labels.csv")
+ dictionnaire_fiches = {}
+
+ try:
+ with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
+ lecteur = csv.DictReader(fichier_csv)
+ for ligne in lecteur:
+ fiche = ligne.get("Fiche")
+ operations = ligne.get("Label opération")
+ item = ligne.get("Label item")
+
+ if fiche and operations and item:
+ dictionnaire_fiches[fiche.strip()] = {
+ "operations": [op.strip() for op in operations.split("/")],
+ "item": item.strip()
+ }
+ except FileNotFoundError:
+ st.error(f"❌ Le fichier {chemin_csv} est introuvable.")
+ except Exception as e:
+ st.error(f"❌ Erreur lors du chargement des fiches : {str(e)}")
+
+ return dictionnaire_fiches
+
+
+def rechercher_tickets_gitea(fiche_selectionnee):
+ params = {"state": "open"}
+ url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
+
+ reponse = gitea_request("get", url, params=params)
+ if not reponse:
+ return []
+
+ try:
+ issues = reponse.json()
+ except Exception as e:
+ st.error(f"Erreur de décodage JSON : {e}")
+ return []
+
+ correspondances = charger_fiches_et_labels()
+ cible = correspondances.get(fiche_selectionnee)
+ if not cible:
+ return []
+
+ labels_cibles = set(cible["operations"] + [cible["item"]])
+ tickets_associes = []
+
+ for issue in issues:
+ if issue.get("ref") != f"refs/heads/{ENV}":
+ continue
+ issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
+ if labels_cibles.issubset(issue_labels):
+ tickets_associes.append(issue)
+
+ return tickets_associes
+
+
+def get_labels_existants():
+ url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
+ reponse = gitea_request("get", url)
+ if not reponse:
+ return {}
+
+ try:
+ return {label['name']: label['id'] for label in reponse.json()}
+ except Exception as e:
+ st.error(f"Erreur de parsing des labels : {e}")
+ return {}
+
+
+def nettoyer_labels(labels):
+ return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
+
+
+def construire_corps_ticket_markdown(reponses):
+ return "\n\n".join(f"## {section}\n{texte}" for section, texte in reponses.items())
+
+
+def creer_ticket_gitea(titre, corps, labels):
+ data = {
+ "title": titre,
+ "body": corps,
+ "labels": labels,
+ "ref": f"refs/heads/{ENV}"
+ }
+ url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
+
+ 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"Ticket créé ! [Voir le ticket]({issue_url})")
+ else:
+ st.success("Ticket créé avec succès.")
diff --git a/utils/tickets/creation.py b/utils/tickets/creation.py
new file mode 100644
index 0000000..19a6582
--- /dev/null
+++ b/utils/tickets/creation.py
@@ -0,0 +1,100 @@
+# creation.py
+
+import re
+import base64
+import streamlit as st
+from .core import charger_fiches_et_labels, construire_corps_ticket_markdown, creer_ticket_gitea, get_labels_existants, nettoyer_labels
+from config import ENV
+import requests
+
+
+def formulaire_creation_ticket_dynamique(fiche_selectionnee):
+ with st.expander("Créer un nouveau ticket lié à cette fiche", expanded=False):
+ contenu_modele = charger_modele_ticket()
+ if not contenu_modele:
+ st.error("Impossible de charger le modèle de ticket.")
+ return
+
+ # Découpe le modèle en sections
+ sections, reponses = {}, {}
+ lignes, titre_courant, contenu = contenu_modele.splitlines(), None, []
+ for ligne in lignes:
+ if ligne.startswith("## "):
+ if titre_courant:
+ sections[titre_courant] = "\n".join(contenu).strip()
+ titre_courant, contenu = ligne[3:].strip(), []
+ elif titre_courant:
+ contenu.append(ligne)
+ if titre_courant:
+ sections[titre_courant] = "\n".join(contenu).strip()
+
+ # Labels prédéfinis selon la fiche
+ labels, selected_ops = [], []
+ correspondances = charger_fiches_et_labels()
+ cible = correspondances.get(fiche_selectionnee)
+ if cible:
+ if len(cible["operations"]) == 1:
+ labels.append(cible["operations"][0])
+ elif len(cible["operations"]) > 1:
+ selected_ops = st.multiselect("Labels opération à associer", cible["operations"], default=cible["operations"])
+
+ # Génération des champs
+ for section, aide in sections.items():
+ if "Type de contribution" in section:
+ options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
+ if "Autre" not in options:
+ options.append("Autre")
+ choix = st.radio("Type de contribution", options)
+ reponses[section] = st.text_input("Précisez", "") if choix == "Autre" else choix
+ elif "Fiche concernée" in section:
+ url_fiche = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/{fiche_selectionnee.replace(' ', '%20')}"
+ reponses[section] = url_fiche
+ st.text_input("Fiche concernée", value=url_fiche, disabled=True)
+ elif "Sujet de la proposition" in section:
+ reponses[section] = st.text_input(section, help=aide)
+ else:
+ reponses[section] = st.text_area(section, help=aide)
+
+ col1, col2 = st.columns(2)
+ if col1.button("Prévisualiser le ticket"):
+ st.session_state.previsualiser = True
+ if col2.button("Annuler"):
+ st.session_state.previsualiser = False
+ st.rerun()
+
+ if st.session_state.get("previsualiser", False):
+ st.subheader("Prévisualisation du ticket")
+ for section, texte in reponses.items():
+ st.markdown(f"#### {section}")
+ st.code(texte, language="markdown")
+
+ titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
+ final_labels = nettoyer_labels(labels + selected_ops + ([cible["item"]] if cible else []))
+
+ st.markdown(f"**Résumé :**\n- **Titre** : `{titre_ticket}`\n- **Labels** : `{', '.join(final_labels)}`")
+
+ if st.button("Confirmer la création du ticket"):
+ labels_existants = get_labels_existants()
+ labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
+ if "Backlog" in labels_existants:
+ labels_ids.append(labels_existants["Backlog"])
+
+ corps = construire_corps_ticket_markdown(reponses)
+ creer_ticket_gitea(titre_ticket, corps, labels_ids)
+
+ st.session_state.previsualiser = False
+ st.success("Ticket créé et formulaire vidé.")
+
+
+def charger_modele_ticket():
+ from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
+
+ headers = {"Authorization": f"token {GITEA_TOKEN}"}
+ url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
+ try:
+ r = requests.get(url, headers=headers, timeout=10)
+ r.raise_for_status()
+ return base64.b64decode(r.json().get("content", "")).decode("utf-8")
+ except Exception as e:
+ st.error(f"Erreur chargement modèle : {e}")
+ return ""
diff --git a/utils/tickets/display.py b/utils/tickets/display.py
new file mode 100644
index 0000000..16b3573
--- /dev/null
+++ b/utils/tickets/display.py
@@ -0,0 +1,105 @@
+# display.py
+
+import streamlit as st
+import html
+import re
+from collections import defaultdict
+from dateutil import parser
+from .core import rechercher_tickets_gitea
+
+
+def extraire_statut_par_label(ticket):
+ labels = [label.get('name', '') for label in ticket.get('labels', [])]
+ for statut in ["Backlog", "En attente de traitement", "En cours", "Terminés", "Non retenus"]:
+ if statut in labels:
+ return statut
+ return "Autres"
+
+
+def afficher_tickets_par_fiche(tickets):
+ if not tickets:
+ st.info("Aucun ticket lié à cette fiche.")
+ return
+
+ st.markdown("**Tickets associés à cette fiche**")
+ tickets_groupes = defaultdict(list)
+ for ticket in tickets:
+ statut = extraire_statut_par_label(ticket)
+ tickets_groupes[statut].append(ticket)
+
+ nb_backlogs = len(tickets_groupes["Backlog"])
+ if nb_backlogs:
+ st.info(f"⤇ {nb_backlogs} ticket(s) en attente de modération ne sont pas affichés.")
+
+ ordre_statuts = ["En attente de traitement", "En cours", "Terminés", "Non retenus", "Autres"]
+ for statut in ordre_statuts:
+ if tickets_groupes[statut]:
+ with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
+ for ticket in tickets_groupes[statut]:
+ afficher_carte_ticket(ticket)
+
+
+def recuperer_commentaires_ticket(issue_index):
+ from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
+ import requests
+
+ headers = {"Authorization": f"token {GITEA_TOKEN}"}
+ url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
+ try:
+ response = requests.get(url, headers=headers, timeout=10)
+ response.raise_for_status()
+ return response.json()
+ except Exception as e:
+ st.error(f"Erreur lors de la récupération des commentaires : {e}")
+ return []
+
+
+def afficher_carte_ticket(ticket):
+ titre = ticket.get("title", "Sans titre")
+ url = ticket.get("html_url", "")
+ user = ticket.get("user", {}).get("login", "inconnu")
+ created = ticket.get("created_at", "")
+ updated = ticket.get("updated_at", "")
+ body = ticket.get("body", "")
+ labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
+
+ sujet = ""
+ match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
+ if match:
+ sujet = match.group(1).strip()
+
+ def format_date(iso):
+ try:
+ return parser.isoparse(iso).strftime("%d/%m/%Y")
+ except:
+ return "?"
+
+ date_created_str = format_date(created)
+ maj_info = f"(MAJ {format_date(updated)})" if updated and updated != created else ""
+
+ commentaires = recuperer_commentaires_ticket(ticket.get("number"))
+ commentaires_html = ""
+ for commentaire in commentaires:
+ auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
+ contenu = html.escape(commentaire.get('body', ''))
+ date = format_date(commentaire.get('created_at', ''))
+ commentaires_html += f"""
+
+ """
+
+ with st.container():
+ st.markdown(f"""
+
+
+
Ouvert par {html.escape(user)} le {date_created_str} {maj_info}
+
Sujet : {html.escape(sujet)}
+
Labels : {' • '.join(labels) if labels else 'aucun'}
+
+ """, unsafe_allow_html=True)
+ st.markdown(body, unsafe_allow_html=False)
+ st.markdown("---")
+ st.markdown("**Commentaire(s) :**")
+ st.markdown(commentaires_html or "Aucun commentaire.", unsafe_allow_html=True)
diff --git a/utils/tickets_fiche.py b/utils/tickets_fiche.py
deleted file mode 100644
index 6d5d6b6..0000000
--- a/utils/tickets_fiche.py
+++ /dev/null
@@ -1,298 +0,0 @@
-import streamlit as st
-from dateutil import parser
-from collections import defaultdict
-import os
-import csv
-import requests
-import base64
-import re
-import json
-import html
-
-# Configuration Gitea
-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")
-ENV = os.getenv("ENV")
-
-def charger_fiches_et_labels():
- chemin_csv = os.path.join("assets", "fiches_labels.csv")
- dictionnaire_fiches = {}
-
- try:
- with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
- lecteur = csv.DictReader(fichier_csv)
- for ligne in lecteur:
- fiche = ligne.get("Fiche")
- operations = ligne.get("Label opération")
- item = ligne.get("Label item")
-
- if fiche and operations and item:
- dictionnaire_fiches[fiche.strip()] = {
- "operations": [op.strip() for op in operations.split("/")],
- "item": item.strip()
- }
- except FileNotFoundError:
- st.error(f"❌ Le fichier {chemin_csv} est introuvable.")
- except Exception as e:
- st.error(f"❌ Erreur lors du chargement des fiches : {str(e)}")
-
- return dictionnaire_fiches
-
-def rechercher_tickets_gitea(fiche_selectionnee):
- headers = {"Authorization": f"token {GITEA_TOKEN}"}
- params = {"state": "open"}
- url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
-
- try:
- reponse = requests.get(url, headers=headers, params=params, timeout=10)
- reponse.raise_for_status()
- issues = reponse.json()
-
- correspondances = charger_fiches_et_labels()
- cible = correspondances.get(fiche_selectionnee)
-
- if not cible:
- return []
-
- labels_cibles = set(cible["operations"] + [cible["item"]])
- tickets_associes = []
-
- for issue in issues:
- if issue.get("ref") != f"refs/heads/{ENV}":
- continue
- issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
- if labels_cibles.issubset(issue_labels):
- tickets_associes.append(issue)
-
- return tickets_associes
-
- except requests.RequestException as e:
- st.error(f"Erreur lors de la récupération des tickets : {e}")
- return []
-def extraire_statut_par_label(ticket):
- labels = [label.get('name', '') for label in ticket.get('labels', [])]
- for statut in ["Backlog", "En attente de traitement", "En cours", "Terminés", "Non retenus"]:
- if statut in labels:
- return statut
- return "Autres"
-
-def afficher_tickets_par_fiche(tickets):
- if not tickets:
- st.info("Aucun ticket lié à cette fiche.")
- return
-
- st.markdown("**Tickets associés à cette fiche**")
- tickets_groupes = defaultdict(list)
- for ticket in tickets:
- statut = extraire_statut_par_label(ticket)
- tickets_groupes[statut].append(ticket)
-
- nb_backlogs = len(tickets_groupes["Backlog"])
- if nb_backlogs:
- st.info(f"⤇ {nb_backlogs} ticket(s) en attente de modération ne sont pas affichés.")
-
- ordre_statuts = ["En attente de traitement", "En cours", "Terminés", "Non retenus", "Autres"]
- for statut in ordre_statuts:
- if tickets_groupes[statut]:
- with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
- for ticket in tickets_groupes[statut]:
- afficher_carte_ticket(ticket)
-
-def recuperer_commentaires_ticket(issue_index):
- headers = {"Authorization": f"token {GITEA_TOKEN}"}
- url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
- try:
- response = requests.get(url, headers=headers, timeout=10)
- response.raise_for_status()
- return response.json()
- except Exception as e:
- st.error(f"Erreur lors de la récupération des commentaires : {e}")
- return []
-
-def get_labels_existants():
- headers = {"Authorization": f"token {GITEA_TOKEN}"}
- url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
- try:
- response = requests.get(url, headers=headers, timeout=10)
- response.raise_for_status()
- return {label['name']: label['id'] for label in response.json()}
- except Exception as e:
- st.error(f"Erreur lors de la récupération des labels : {e}")
- return {}
-
-def nettoyer_labels(labels):
- return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
-
-def construire_corps_ticket_markdown(reponses):
- return "\n\n".join(f"## {section}\n{texte}" for section, texte in reponses.items())
-
-def creer_ticket_gitea(titre, corps, labels):
- headers = {
- "Authorization": f"token {GITEA_TOKEN}",
- "Content-Type": "application/json"
- }
- data = {
- "title": titre,
- "body": corps,
- "labels": labels,
- "ref": f"refs/heads/{ENV}"
- }
- url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
-
- try:
- response = requests.post(url, headers=headers, data=json.dumps(data), timeout=10)
- response.raise_for_status()
- issue_url = response.json().get("html_url", "")
- if issue_url:
- st.success(f"Ticket créé ! [Voir le ticket]({issue_url})")
- else:
- st.success("Ticket créé avec succès.")
- except Exception as e:
- st.error(f"❌ Erreur création ticket : {e}")
-
-def afficher_carte_ticket(ticket):
- titre = ticket.get("title", "Sans titre")
- url = ticket.get("html_url", "")
- user = ticket.get("user", {}).get("login", "inconnu")
- created = ticket.get("created_at", "")
- updated = ticket.get("updated_at", "")
- body = ticket.get("body", "")
- labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
-
- sujet = ""
- match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
- if match:
- sujet = match.group(1).strip()
-
- def format_date(iso):
- try:
- return parser.isoparse(iso).strftime("%d/%m/%Y")
- except:
- return "?"
-
- date_created_str = format_date(created)
- maj_info = f"(MAJ {format_date(updated)})" if updated and updated != created else ""
-
- commentaires = recuperer_commentaires_ticket(ticket.get("number"))
- commentaires_html = ""
- for commentaire in commentaires:
- auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
- contenu = html.escape(commentaire.get('body', ''))
- date = format_date(commentaire.get('created_at', ''))
- commentaires_html += f"""
-
- """
-
- with st.container():
- st.markdown(f"""
-
-
-
Ouvert par {html.escape(user)} le {date_created_str} {maj_info}
-
Sujet : {html.escape(sujet)}
-
Labels : {' • '.join(labels) if labels else 'aucun'}
-
- """, unsafe_allow_html=True)
- st.markdown(body, unsafe_allow_html=False)
- st.markdown("---")
- st.markdown("**Commentaire(s) :**")
- st.markdown(commentaires_html or "Aucun commentaire.", unsafe_allow_html=True)
-
-def charger_modele_ticket():
- headers = {"Authorization": f"token {GITEA_TOKEN}"}
- url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
- try:
- r = requests.get(url, headers=headers, timeout=10)
- r.raise_for_status()
- return base64.b64decode(r.json().get("content", "")).decode("utf-8")
- except Exception as e:
- st.error(f"Erreur chargement modèle : {e}")
- return ""
-
-
-def gerer_tickets_fiche(fiche_selectionnee):
- st.markdown("
", unsafe_allow_html=True)
- st.markdown("## Gestion des tickets pour cette fiche")
- afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_selectionnee))
- formulaire_creation_ticket_dynamique(fiche_selectionnee)
-
-def formulaire_creation_ticket_dynamique(fiche_selectionnee):
- with st.expander("Créer un nouveau ticket lié à cette fiche", expanded=False):
- contenu_modele = charger_modele_ticket()
- if not contenu_modele:
- st.error("Impossible de charger le modèle de ticket.")
- return
-
- # Découpe le modèle en sections
- sections, reponses = {}, {}
- lignes, titre_courant, contenu = contenu_modele.splitlines(), None, []
- for ligne in lignes:
- if ligne.startswith("## "):
- if titre_courant:
- sections[titre_courant] = "\n".join(contenu).strip()
- titre_courant, contenu = ligne[3:].strip(), []
- elif titre_courant:
- contenu.append(ligne)
- if titre_courant:
- sections[titre_courant] = "\n".join(contenu).strip()
-
- # Labels prédéfinis selon la fiche
- labels, selected_ops = [], []
- correspondances = charger_fiches_et_labels()
- cible = correspondances.get(fiche_selectionnee)
- if cible:
- if len(cible["operations"]) == 1:
- labels.append(cible["operations"][0])
- elif len(cible["operations"]) > 1:
- selected_ops = st.multiselect("Labels opération à associer", cible["operations"], default=cible["operations"])
-
- # Génération des champs
- for section, aide in sections.items():
- if "Type de contribution" in section:
- options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
- if "Autre" not in options:
- options.append("Autre")
- choix = st.radio("Type de contribution", options)
- reponses[section] = st.text_input("Précisez", "") if choix == "Autre" else choix
- elif "Fiche concernée" in section:
- url_fiche = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/{fiche_selectionnee.replace(' ', '%20')}"
- reponses[section] = url_fiche
- st.text_input("Fiche concernée", value=url_fiche, disabled=True)
- elif "Sujet de la proposition" in section:
- reponses[section] = st.text_input(section, help=aide)
- else:
- reponses[section] = st.text_area(section, help=aide)
-
- col1, col2 = st.columns(2)
- if col1.button("Prévisualiser le ticket"):
- st.session_state.previsualiser = True
- if col2.button("Annuler"):
- st.session_state.previsualiser = False
- st.rerun()
-
- if st.session_state.get("previsualiser", False):
- st.subheader("Prévisualisation du ticket")
- for section, texte in reponses.items():
- st.markdown(f"#### {section}")
- st.code(texte, language="markdown")
-
- titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
- final_labels = nettoyer_labels(labels + selected_ops + ([cible["item"]] if cible else []))
-
- st.markdown(f"**Résumé :**\n- **Titre** : `{titre_ticket}`\n- **Labels** : `{', '.join(final_labels)}`")
-
- if st.button("Confirmer la création du ticket"):
- labels_existants = get_labels_existants()
- labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
- if "Backlog" in labels_existants:
- labels_ids.append(labels_existants["Backlog"])
-
- corps = construire_corps_ticket_markdown(reponses)
- creer_ticket_gitea(titre_ticket, corps, labels_ids)
-
- st.session_state.previsualiser = False
- st.success("Ticket créé et formulaire vidé.")
{auteur} ({date})
+{contenu}
+