Spaces:
Runtime error
Runtime error
Pierre RAFFALLI commited on
Commit ·
0946dfa
1
Parent(s): 3bafa5f
new features
Browse files- app.py +572 -0
- config.py +139 -0
- data_loader.py +415 -0
- flowchart_engine.py +1286 -0
- llm_service.py +717 -0
- logigramme.json +163 -0
- logigramme_mineral.json +93 -0
- logigramme_soja.json +124 -0
- requirements.txt +0 -1
app.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py - Application Streamlit pour la détermination de l'impact carbone
|
| 3 |
+
des matières premières pour aliments composés.
|
| 4 |
+
|
| 5 |
+
Lancement : streamlit run app.py
|
| 6 |
+
"""
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from flowchart_engine import evaluate_carbon_impact, CarbonResult
|
| 11 |
+
import llm_service
|
| 12 |
+
import data_loader
|
| 13 |
+
import config
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ============================================================================
|
| 17 |
+
# Configuration de la page
|
| 18 |
+
# ============================================================================
|
| 19 |
+
st.set_page_config(
|
| 20 |
+
page_title="POC GAIA - Impact Carbone MP",
|
| 21 |
+
page_icon="🌿",
|
| 22 |
+
layout="wide",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
st.title("🌿 POC GAIA — Impact Carbone des Matières Premières")
|
| 26 |
+
st.markdown("""
|
| 27 |
+
**Logigramme d'aide pour faciliter l'application du Guide de calcul de l'impact carbone
|
| 28 |
+
des aliments composés** *(GT Carbone — hors produits dérivés du soja)*
|
| 29 |
+
|
| 30 |
+
Renseignez les informations sur votre matière première ci-dessous, puis lancez l'évaluation.
|
| 31 |
+
""")
|
| 32 |
+
|
| 33 |
+
st.divider()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ============================================================================
|
| 37 |
+
# Listes de suggestions (chargées une fois, mises en cache par Streamlit)
|
| 38 |
+
# ============================================================================
|
| 39 |
+
@st.cache_data
|
| 40 |
+
def get_country_list():
|
| 41 |
+
"""Retourne la liste des noms de pays disponibles (clés du mapping FR→ISO)."""
|
| 42 |
+
noms = sorted(set(k.title() for k in config.PAYS_FR_TO_ISO.keys()))
|
| 43 |
+
return noms
|
| 44 |
+
|
| 45 |
+
# ============================================================================
|
| 46 |
+
# Composant autocomplete maison
|
| 47 |
+
# ============================================================================
|
| 48 |
+
def autocomplete_input(label: str, options: list[str], key: str,
|
| 49 |
+
placeholder: str = "", help_text: str = "",
|
| 50 |
+
max_suggestions: int = 8) -> str:
|
| 51 |
+
"""
|
| 52 |
+
Champ texte libre avec suggestions filtrées cliquables en dessous.
|
| 53 |
+
L'utilisateur peut taper librement ou cliquer une suggestion.
|
| 54 |
+
"""
|
| 55 |
+
# Si une suggestion a été cliquée au tour précédent, injecter la valeur
|
| 56 |
+
sel_key = f"{key}__sel"
|
| 57 |
+
if sel_key in st.session_state:
|
| 58 |
+
st.session_state[key] = st.session_state.pop(sel_key)
|
| 59 |
+
|
| 60 |
+
typed = st.text_input(label, key=key, placeholder=placeholder, help=help_text)
|
| 61 |
+
|
| 62 |
+
if typed and len(typed) >= 2:
|
| 63 |
+
typed_lower = typed.lower().strip()
|
| 64 |
+
# Filtrer + trier : commence par > contient, puis longueur croissante
|
| 65 |
+
matches = [
|
| 66 |
+
o for o in options
|
| 67 |
+
if typed_lower in o.lower()
|
| 68 |
+
]
|
| 69 |
+
matches.sort(key=lambda x: (not x.lower().startswith(typed_lower), len(x)))
|
| 70 |
+
matches = matches[:max_suggestions]
|
| 71 |
+
|
| 72 |
+
# Ne pas afficher si l'input est déjà un match exact
|
| 73 |
+
if matches and typed not in matches:
|
| 74 |
+
cols = st.columns(min(len(matches), 4))
|
| 75 |
+
for i, sugg in enumerate(matches):
|
| 76 |
+
with cols[i % 4]:
|
| 77 |
+
if st.button(f"➜ {sugg}", key=f"{key}__sug_{i}", use_container_width=True):
|
| 78 |
+
st.session_state[sel_key] = sugg
|
| 79 |
+
st.rerun()
|
| 80 |
+
|
| 81 |
+
return typed or ""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ============================================================================
|
| 85 |
+
# Formulaire d'entrée
|
| 86 |
+
# ============================================================================
|
| 87 |
+
col_form, col_info = st.columns([2, 1])
|
| 88 |
+
|
| 89 |
+
with col_form:
|
| 90 |
+
st.subheader("📝 Formulaire de saisie")
|
| 91 |
+
|
| 92 |
+
matiere = st.text_input(
|
| 93 |
+
"Nom de la matière première",
|
| 94 |
+
key="input_matiere",
|
| 95 |
+
placeholder="Ex : BLE, T.TNSL DEC., ORGE, T. COLZA, LUZERNE…",
|
| 96 |
+
help="Entrez le nom usuel de la matière première.",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
provenance_connue = st.radio(
|
| 100 |
+
"Connaissez-vous la provenance de la matière première ?",
|
| 101 |
+
options=["Oui", "Non"],
|
| 102 |
+
horizontal=True,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
pays_production = None
|
| 106 |
+
pays_transformation = None
|
| 107 |
+
|
| 108 |
+
if provenance_connue == "Oui":
|
| 109 |
+
pays_production = autocomplete_input(
|
| 110 |
+
"Pays de production primaire de la MP brute",
|
| 111 |
+
get_country_list(),
|
| 112 |
+
key="input_pays_prod",
|
| 113 |
+
placeholder="Ex : France, Brésil, Ukraine…",
|
| 114 |
+
help_text="Tapez les premières lettres et cliquez sur une suggestion, ou saisissez librement.",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
pays_transformation = autocomplete_input(
|
| 118 |
+
"Pays de transformation (laisser vide si MP brute)",
|
| 119 |
+
get_country_list(),
|
| 120 |
+
key="input_pays_transfo",
|
| 121 |
+
placeholder="Ex : France, Allemagne… (vide si non transformée)",
|
| 122 |
+
help_text="Pays où la transformation a eu lieu. Laissez vide si la matière est brute.",
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
st.markdown("---")
|
| 126 |
+
run_button = st.button("🚀 Évaluer l'impact carbone", type="primary", use_container_width=True)
|
| 127 |
+
|
| 128 |
+
with col_info:
|
| 129 |
+
st.subheader("ℹ️ Règles générales")
|
| 130 |
+
st.info("""
|
| 131 |
+
- Le choix des valeurs par défaut relève de la responsabilité des entreprises.
|
| 132 |
+
- Il faut attribuer un facteur d'émission à **tous** les intrants.
|
| 133 |
+
- Si provenance inconnue → valeur la **plus défavorable**.
|
| 134 |
+
- Si provenance connue mais donnée inexistante → donnée générique la **plus pertinente**.
|
| 135 |
+
- Bases : **GFLI** et **ECOALIM** en priorité.
|
| 136 |
+
""")
|
| 137 |
+
|
| 138 |
+
st.warning("⚠️ **Hors produits dérivés du soja** — Ce logigramme ne s'applique pas aux dérivés de soja.")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ============================================================================
|
| 142 |
+
# Exécution et affichage des résultats
|
| 143 |
+
# ============================================================================
|
| 144 |
+
if run_button:
|
| 145 |
+
if not matiere:
|
| 146 |
+
st.error("❌ Veuillez saisir un nom de matière première.")
|
| 147 |
+
st.stop()
|
| 148 |
+
|
| 149 |
+
# Nettoyage des entrées
|
| 150 |
+
pays_prod = pays_production.strip() if pays_production and pays_production.strip() else None
|
| 151 |
+
pays_transfo = pays_transformation.strip() if pays_transformation and pays_transformation.strip() else None
|
| 152 |
+
|
| 153 |
+
with st.spinner("🔄 Évaluation en cours… (classification LLM + recherche dans les bases de données)"):
|
| 154 |
+
result: CarbonResult = evaluate_carbon_impact(
|
| 155 |
+
matiere_premiere=matiere.strip(),
|
| 156 |
+
pays_production=pays_prod,
|
| 157 |
+
pays_transformation=pays_transfo,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Stocker le résultat et la matière dans session_state pour persistance
|
| 161 |
+
st.session_state["last_result"] = result
|
| 162 |
+
st.session_state["last_matiere"] = matiere.strip()
|
| 163 |
+
# Nettoyer les anciennes alternatives manuelles
|
| 164 |
+
st.session_state.pop("searched_alternatives", None)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ============================================================================
|
| 168 |
+
# Affichage des résultats (depuis session_state — persiste entre reruns)
|
| 169 |
+
# ============================================================================
|
| 170 |
+
if "last_result" in st.session_state:
|
| 171 |
+
result = st.session_state["last_result"]
|
| 172 |
+
|
| 173 |
+
st.divider()
|
| 174 |
+
|
| 175 |
+
# ------------------------------------------------------------------
|
| 176 |
+
# Section 0 : Produits candidats
|
| 177 |
+
# ------------------------------------------------------------------
|
| 178 |
+
if result.candidats_alternatifs:
|
| 179 |
+
st.subheader("📋 Produits candidats")
|
| 180 |
+
if not result.match_exact:
|
| 181 |
+
st.warning("⚠️ Pas de correspondance exacte — choisissez un produit proche si besoin.")
|
| 182 |
+
else:
|
| 183 |
+
st.info("ℹ️ Autres produits correspondant à la recherche.")
|
| 184 |
+
|
| 185 |
+
if result.candidats_reflexion:
|
| 186 |
+
st.markdown("**Avis du LLM :**")
|
| 187 |
+
if result.candidat_recommande:
|
| 188 |
+
st.markdown(f"Meilleur candidat proposé : **{result.candidat_recommande}**")
|
| 189 |
+
st.info(result.candidats_reflexion)
|
| 190 |
+
|
| 191 |
+
# En-têtes
|
| 192 |
+
head = st.columns([6, 3, 2])
|
| 193 |
+
head[0].markdown("**Intrant**")
|
| 194 |
+
head[1].markdown("**Impact (kg CO2 eq / t)**")
|
| 195 |
+
head[2].markdown("**Base**")
|
| 196 |
+
|
| 197 |
+
for i, cand in enumerate(result.candidats_alternatifs):
|
| 198 |
+
nom = cand.get("nom", "")
|
| 199 |
+
impact = cand.get("impact", 0)
|
| 200 |
+
unite = str(cand.get("unite", ""))
|
| 201 |
+
source = cand.get("source", "")
|
| 202 |
+
source_upper = source.upper()
|
| 203 |
+
is_gfli = "GFLI" in source_upper
|
| 204 |
+
if "tonne" in unite or is_gfli:
|
| 205 |
+
impact_kg_t = impact
|
| 206 |
+
else:
|
| 207 |
+
# EcoALIM : kg/kg -> kg/t (x1000)
|
| 208 |
+
impact_kg_t = impact * 1000.0
|
| 209 |
+
|
| 210 |
+
row = st.columns([6, 3, 2])
|
| 211 |
+
row[0].markdown(nom)
|
| 212 |
+
row[1].markdown(f"{impact_kg_t:.2f}")
|
| 213 |
+
row[2].markdown(source if source else "—")
|
| 214 |
+
|
| 215 |
+
st.divider()
|
| 216 |
+
|
| 217 |
+
# Section 0b : 4 alternatives (fallback)
|
| 218 |
+
# ------------------------------------------------------------------
|
| 219 |
+
if result.alternatives_combined or result.alternatives_itinerary:
|
| 220 |
+
st.subheader("🎯 4 Alternatives proposées (absence de correspondance)")
|
| 221 |
+
st.info("Quand aucune matière exacte n'est trouvée, voici 4 propositions pour substitution :")
|
| 222 |
+
|
| 223 |
+
# Créer 4 colonnes
|
| 224 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 225 |
+
|
| 226 |
+
# Alternative 1: ITINERARY
|
| 227 |
+
with col1:
|
| 228 |
+
if result.alternatives_itinerary:
|
| 229 |
+
alt = result.alternatives_itinerary
|
| 230 |
+
st.markdown("### 🔄 Itinéraire")
|
| 231 |
+
st.markdown(f"**{alt['name']}**")
|
| 232 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 233 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 234 |
+
with st.expander("Raison"):
|
| 235 |
+
st.markdown(alt['reasoning'])
|
| 236 |
+
else:
|
| 237 |
+
st.markdown("### 🔄 Itinéraire")
|
| 238 |
+
st.caption("Non disponible")
|
| 239 |
+
|
| 240 |
+
# Alternative 2: LOCALITY
|
| 241 |
+
with col2:
|
| 242 |
+
if result.alternatives_locality:
|
| 243 |
+
alt = result.alternatives_locality
|
| 244 |
+
st.markdown("### 📍 Localité")
|
| 245 |
+
st.markdown(f"**{alt['name']}**")
|
| 246 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 247 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 248 |
+
with st.expander("Raison"):
|
| 249 |
+
st.markdown(alt['reasoning'])
|
| 250 |
+
else:
|
| 251 |
+
st.markdown("### 📍 Localité")
|
| 252 |
+
st.caption("Non disponible")
|
| 253 |
+
|
| 254 |
+
# Alternative 3: FORM
|
| 255 |
+
with col3:
|
| 256 |
+
if result.alternatives_form:
|
| 257 |
+
alt = result.alternatives_form
|
| 258 |
+
st.markdown("### 🌱 Forme structurelle")
|
| 259 |
+
st.markdown(f"**{alt['name']}**")
|
| 260 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 261 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 262 |
+
with st.expander("Raison"):
|
| 263 |
+
st.markdown(alt['reasoning'])
|
| 264 |
+
else:
|
| 265 |
+
st.markdown("### 🌱 Forme structurelle")
|
| 266 |
+
st.caption("Non disponible")
|
| 267 |
+
|
| 268 |
+
# Alternative 4: COMBINED
|
| 269 |
+
with col4:
|
| 270 |
+
if result.alternatives_combined:
|
| 271 |
+
alt = result.alternatives_combined
|
| 272 |
+
st.markdown("### ✨ Meilleur compromis")
|
| 273 |
+
st.markdown(f"**{alt['name']}**")
|
| 274 |
+
st.metric("Impact", f"{alt['impact']:.2f}", delta="RECOMMANDÉ ✓")
|
| 275 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 276 |
+
with st.expander("Raison"):
|
| 277 |
+
st.markdown(alt['reasoning'])
|
| 278 |
+
else:
|
| 279 |
+
st.markdown("### ✨ Meilleur compromis")
|
| 280 |
+
st.caption("Non disponible")
|
| 281 |
+
|
| 282 |
+
st.divider()
|
| 283 |
+
|
| 284 |
+
# ------------------------------------------------------------------
|
| 285 |
+
# Section 1 : Résultat principal
|
| 286 |
+
# ------------------------------------------------------------------
|
| 287 |
+
st.subheader("📊 Résultat de l'impact carbone")
|
| 288 |
+
|
| 289 |
+
if result.erreur:
|
| 290 |
+
st.error(f"❌ {result.erreur}")
|
| 291 |
+
else:
|
| 292 |
+
col1, col2, col3 = st.columns(3)
|
| 293 |
+
|
| 294 |
+
with col1:
|
| 295 |
+
if result.impact_kg_co2_eq is not None:
|
| 296 |
+
# GFLI : kg CO2 eq / t ; EcoALIM : kg/kg -> kg/t
|
| 297 |
+
if "tonne" in (result.unite_source or ""):
|
| 298 |
+
impact_kg_t = result.impact_kg_co2_eq
|
| 299 |
+
else:
|
| 300 |
+
impact_kg_t = result.impact_kg_co2_eq * 1000.0
|
| 301 |
+
|
| 302 |
+
st.metric(
|
| 303 |
+
label="Impact carbone",
|
| 304 |
+
value=f"{impact_kg_t:.2f}",
|
| 305 |
+
delta="kg CO2 eq / t produit",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
with col2:
|
| 309 |
+
st.markdown(f"**Source :** {result.source_db}")
|
| 310 |
+
st.markdown(f"**Intrant utilisé :** {result.intrant_utilise}")
|
| 311 |
+
st.markdown(f"**Match exact :** {'✅ Oui' if result.match_exact else '⚠️ Non — valeur approchée'}")
|
| 312 |
+
|
| 313 |
+
with col3:
|
| 314 |
+
st.markdown(f"**Classification :** {'🏭 Transformé' if result.classification == 'transforme' else '🌾 Brut'}")
|
| 315 |
+
st.markdown(f"**Node résultat :** `{result.node_resultat}`")
|
| 316 |
+
if result.pays_production:
|
| 317 |
+
st.markdown(f"**Pays production :** {result.pays_production}")
|
| 318 |
+
if result.pays_transformation:
|
| 319 |
+
st.markdown(f"**Pays transformation :** {result.pays_transformation}")
|
| 320 |
+
|
| 321 |
+
# ------------------------------------------------------------------
|
| 322 |
+
# Section 0c : Bouton "Chercher une alternative" si match non exact
|
| 323 |
+
# ------------------------------------------------------------------
|
| 324 |
+
if not result.match_exact and result.impact_kg_co2_eq is not None:
|
| 325 |
+
st.divider()
|
| 326 |
+
st.info("💡 La correspondance n'est pas exacte. Vous pouvez chercher d'autres alternatives.")
|
| 327 |
+
|
| 328 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 329 |
+
with col2:
|
| 330 |
+
if st.button("🔍 Chercher une alternative plus proche", use_container_width=True, key="btn_find_alternative"):
|
| 331 |
+
matiere_search = st.session_state.get("last_matiere", "")
|
| 332 |
+
with st.spinner("Recherche des 4 alternatives en cours..."):
|
| 333 |
+
# Déterminer la base GFLI ou EcoALIM selon le source_db
|
| 334 |
+
db_name = "GFLI" if "GFLI" in (result.source_db or "") else "ECOALIM"
|
| 335 |
+
|
| 336 |
+
# Déterminer le pays_hint si applicable
|
| 337 |
+
country_hint = result.pays_production or result.pays_transformation
|
| 338 |
+
|
| 339 |
+
# Forcer la recherche des alternatives
|
| 340 |
+
alternatives = llm_service.find_alternative_materials(
|
| 341 |
+
matiere_search,
|
| 342 |
+
db_name=db_name,
|
| 343 |
+
country_hint=country_hint
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if alternatives:
|
| 347 |
+
st.session_state["searched_alternatives"] = {
|
| 348 |
+
"itinerary": alternatives.get("itinerary"),
|
| 349 |
+
"locality": alternatives.get("locality"),
|
| 350 |
+
"form": alternatives.get("form"),
|
| 351 |
+
"combined": alternatives.get("combined"),
|
| 352 |
+
}
|
| 353 |
+
st.rerun()
|
| 354 |
+
else:
|
| 355 |
+
st.error("❌ Pas d'alternatives trouvées.")
|
| 356 |
+
|
| 357 |
+
# Afficher les alternatives trouvées via bouton (persistées en session_state)
|
| 358 |
+
if "searched_alternatives" in st.session_state:
|
| 359 |
+
st.subheader("🎯 Alternatives recherchées")
|
| 360 |
+
st.info("Alternatives générées suite à votre demande :")
|
| 361 |
+
|
| 362 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 363 |
+
|
| 364 |
+
with col1:
|
| 365 |
+
alt = st.session_state["searched_alternatives"].get("itinerary")
|
| 366 |
+
if alt:
|
| 367 |
+
st.markdown("### 🔄 Itinéraire")
|
| 368 |
+
st.markdown(f"**{alt['name']}**")
|
| 369 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 370 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 371 |
+
with st.expander("Raison"):
|
| 372 |
+
st.markdown(alt['reasoning'])
|
| 373 |
+
else:
|
| 374 |
+
st.markdown("### 🔄 Itinéraire")
|
| 375 |
+
st.caption("Non disponible")
|
| 376 |
+
|
| 377 |
+
with col2:
|
| 378 |
+
alt = st.session_state["searched_alternatives"].get("locality")
|
| 379 |
+
if alt:
|
| 380 |
+
st.markdown("### 📍 Localité")
|
| 381 |
+
st.markdown(f"**{alt['name']}**")
|
| 382 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 383 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 384 |
+
with st.expander("Raison"):
|
| 385 |
+
st.markdown(alt['reasoning'])
|
| 386 |
+
else:
|
| 387 |
+
st.markdown("### 📍 Localité")
|
| 388 |
+
st.caption("Non disponible")
|
| 389 |
+
|
| 390 |
+
with col3:
|
| 391 |
+
alt = st.session_state["searched_alternatives"].get("form")
|
| 392 |
+
if alt:
|
| 393 |
+
st.markdown("### 🌱 Forme structurelle")
|
| 394 |
+
st.markdown(f"**{alt['name']}**")
|
| 395 |
+
st.metric("Impact", f"{alt['impact']:.2f}")
|
| 396 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 397 |
+
with st.expander("Raison"):
|
| 398 |
+
st.markdown(alt['reasoning'])
|
| 399 |
+
else:
|
| 400 |
+
st.markdown("### 🌱 Forme structurelle")
|
| 401 |
+
st.caption("Non disponible")
|
| 402 |
+
|
| 403 |
+
with col4:
|
| 404 |
+
alt = st.session_state["searched_alternatives"].get("combined")
|
| 405 |
+
if alt:
|
| 406 |
+
st.markdown("### ✨ Meilleur compromis")
|
| 407 |
+
st.markdown(f"**{alt['name']}**")
|
| 408 |
+
st.metric("Impact", f"{alt['impact']:.2f}", delta="RECOMMANDÉ ✓")
|
| 409 |
+
st.caption(f"kg CO2 eq/t | Source: {alt['source']}")
|
| 410 |
+
with st.expander("Raison"):
|
| 411 |
+
st.markdown(alt['reasoning'])
|
| 412 |
+
else:
|
| 413 |
+
st.markdown("### ✨ Meilleur compromis")
|
| 414 |
+
st.caption("Non disponible")
|
| 415 |
+
|
| 416 |
+
st.divider()
|
| 417 |
+
|
| 418 |
+
# ------------------------------------------------------------------
|
| 419 |
+
# Section 2 : Parcours de logique (logigramme)
|
| 420 |
+
# ------------------------------------------------------------------
|
| 421 |
+
st.subheader("🔀 Parcours du logigramme")
|
| 422 |
+
|
| 423 |
+
for i, step in enumerate(result.parcours):
|
| 424 |
+
with st.expander(f"Étape {i+1} — {step.node_id}", expanded=(i == 0)):
|
| 425 |
+
if step.question:
|
| 426 |
+
st.markdown(f"**Question :** {step.question}")
|
| 427 |
+
if step.answer:
|
| 428 |
+
st.markdown(f"**Réponse :** {step.answer}")
|
| 429 |
+
if step.action:
|
| 430 |
+
st.markdown(f"**Action :** {step.action}")
|
| 431 |
+
|
| 432 |
+
# ------------------------------------------------------------------
|
| 433 |
+
# Section 3 : Actions appliquées (recherche dans les BDD)
|
| 434 |
+
# ------------------------------------------------------------------
|
| 435 |
+
st.subheader("🔍 Détail des recherches effectuées")
|
| 436 |
+
|
| 437 |
+
import re
|
| 438 |
+
|
| 439 |
+
def _format_action_line(line: str) -> str:
|
| 440 |
+
"""Convertit les impacts affiches en kg CO2 eq / t produit."""
|
| 441 |
+
m = re.search(r"=\s*([0-9]+(?:\.[0-9]+)?)\s*kg\s*CO2\s*eq\s*/\s*t", line)
|
| 442 |
+
if m:
|
| 443 |
+
val = float(m.group(1))
|
| 444 |
+
return re.sub(r"=\s*[0-9]+(?:\.[0-9]+)?\s*kg\s*CO2\s*eq\s*/\s*t",
|
| 445 |
+
f"= {val:.2f} kg CO2 eq / t", line)
|
| 446 |
+
m = re.search(r"=\s*([0-9]+(?:\.[0-9]+)?)\s*kg\s*CO2\s*eq\s*/\s*kg", line)
|
| 447 |
+
if m:
|
| 448 |
+
val = float(m.group(1)) * 1000.0
|
| 449 |
+
return re.sub(r"=\s*[0-9]+(?:\.[0-9]+)?\s*kg\s*CO2\s*eq\s*/\s*kg",
|
| 450 |
+
f"= {val:.2f} kg CO2 eq / t", line)
|
| 451 |
+
return line
|
| 452 |
+
|
| 453 |
+
for action in result.actions_appliquees:
|
| 454 |
+
line = _format_action_line(action)
|
| 455 |
+
if line.startswith(" →"):
|
| 456 |
+
st.success(line)
|
| 457 |
+
else:
|
| 458 |
+
st.markdown(f"- {line}")
|
| 459 |
+
|
| 460 |
+
# ------------------------------------------------------------------
|
| 461 |
+
# Section 4 : Justification si valeur alternative
|
| 462 |
+
# ------------------------------------------------------------------
|
| 463 |
+
if result.justification_alternative:
|
| 464 |
+
st.subheader("💡 Justification du choix de valeur")
|
| 465 |
+
st.info(result.justification_alternative)
|
| 466 |
+
|
| 467 |
+
# ------------------------------------------------------------------
|
| 468 |
+
# Section 5 : Classification détaillée
|
| 469 |
+
# ------------------------------------------------------------------
|
| 470 |
+
with st.expander("📋 Détail de la classification brut/transformé"):
|
| 471 |
+
st.markdown(f"**Classification :** {result.classification}")
|
| 472 |
+
st.markdown(f"**Justification :** {result.classification_justification}")
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
# ============================================================================
|
| 476 |
+
# Mode batch (facultatif)
|
| 477 |
+
# ============================================================================
|
| 478 |
+
st.divider()
|
| 479 |
+
st.subheader("📁 Évaluation par lot (import fichier)")
|
| 480 |
+
st.markdown("Importez un fichier Excel avec les colonnes : `Matière première`, `Pays de la production primaire de la MP brute`, `Pays de la transformation`")
|
| 481 |
+
|
| 482 |
+
uploaded_file = st.file_uploader("Choisir un fichier Excel", type=["xlsx", "xls"])
|
| 483 |
+
|
| 484 |
+
if uploaded_file is not None:
|
| 485 |
+
try:
|
| 486 |
+
df_input = pd.read_excel(uploaded_file)
|
| 487 |
+
st.dataframe(df_input, use_container_width=True)
|
| 488 |
+
|
| 489 |
+
# Chercher les colonnes par nom approximatif
|
| 490 |
+
col_mapping = {}
|
| 491 |
+
for col in df_input.columns:
|
| 492 |
+
col_lower = str(col).lower()
|
| 493 |
+
if "matière" in col_lower or "matiere" in col_lower or "mp" in col_lower:
|
| 494 |
+
col_mapping["matiere"] = col
|
| 495 |
+
elif "production" in col_lower or "pays" in col_lower and "transf" not in col_lower:
|
| 496 |
+
if "matiere" not in col_lower:
|
| 497 |
+
col_mapping["pays_prod"] = col
|
| 498 |
+
elif "transf" in col_lower:
|
| 499 |
+
col_mapping["pays_transfo"] = col
|
| 500 |
+
|
| 501 |
+
if "matiere" not in col_mapping:
|
| 502 |
+
st.warning("⚠️ Colonne 'Matière première' non détectée. Vérifiez les noms de colonnes.")
|
| 503 |
+
else:
|
| 504 |
+
if st.button("🚀 Lancer l'évaluation par lot", type="primary"):
|
| 505 |
+
results_list = []
|
| 506 |
+
progress_bar = st.progress(0)
|
| 507 |
+
|
| 508 |
+
for idx, row in df_input.iterrows():
|
| 509 |
+
mp = str(row.get(col_mapping["matiere"], "")).strip()
|
| 510 |
+
if not mp or mp == "nan":
|
| 511 |
+
continue
|
| 512 |
+
|
| 513 |
+
pays_p = str(row.get(col_mapping.get("pays_prod", ""), "")).strip()
|
| 514 |
+
pays_t = str(row.get(col_mapping.get("pays_transfo", ""), "")).strip()
|
| 515 |
+
pays_p = pays_p if pays_p and pays_p != "nan" else None
|
| 516 |
+
pays_t = pays_t if pays_t and pays_t != "nan" else None
|
| 517 |
+
|
| 518 |
+
with st.spinner(f"Évaluation de {mp}..."):
|
| 519 |
+
res = evaluate_carbon_impact(mp, pays_p, pays_t)
|
| 520 |
+
|
| 521 |
+
results_list.append({
|
| 522 |
+
"Matière première": mp,
|
| 523 |
+
"Pays production": pays_p or "",
|
| 524 |
+
"Pays transformation": pays_t or "",
|
| 525 |
+
"Classification": res.classification,
|
| 526 |
+
"Impact (kg CO2 eq / t)": (
|
| 527 |
+
res.impact_kg_co2_eq
|
| 528 |
+
if res.impact_kg_co2_eq is None
|
| 529 |
+
else (
|
| 530 |
+
res.impact_kg_co2_eq
|
| 531 |
+
if "tonne" in (res.unite_source or "")
|
| 532 |
+
else res.impact_kg_co2_eq * 1000.0
|
| 533 |
+
)
|
| 534 |
+
),
|
| 535 |
+
"Unité": "kg CO2 eq / t produit",
|
| 536 |
+
"Source": res.source_db,
|
| 537 |
+
"Intrant utilisé": res.intrant_utilise,
|
| 538 |
+
"Match exact": "Oui" if res.match_exact else "Non",
|
| 539 |
+
"Node résultat": res.node_resultat,
|
| 540 |
+
"Justification": res.justification_alternative or "",
|
| 541 |
+
"Erreur": res.erreur or "",
|
| 542 |
+
})
|
| 543 |
+
|
| 544 |
+
progress_bar.progress((idx + 1) / len(df_input))
|
| 545 |
+
|
| 546 |
+
if results_list:
|
| 547 |
+
df_results = pd.DataFrame(results_list)
|
| 548 |
+
st.subheader("📊 Résultats du lot")
|
| 549 |
+
st.dataframe(df_results, use_container_width=True)
|
| 550 |
+
|
| 551 |
+
# Téléchargement
|
| 552 |
+
csv = df_results.to_csv(index=False).encode("utf-8")
|
| 553 |
+
st.download_button(
|
| 554 |
+
label="📥 Télécharger les résultats (CSV)",
|
| 555 |
+
data=csv,
|
| 556 |
+
file_name="resultats_impact_carbone.csv",
|
| 557 |
+
mime="text/csv",
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
except Exception as e:
|
| 561 |
+
st.error(f"Erreur lors de la lecture du fichier : {e}")
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
# ============================================================================
|
| 565 |
+
# Footer
|
| 566 |
+
# ============================================================================
|
| 567 |
+
st.divider()
|
| 568 |
+
st.caption(
|
| 569 |
+
"POC GAIA — Outil d'aide à la détermination de l'impact carbone des matières premières "
|
| 570 |
+
"pour aliments composés. Basé sur le Guide GT Carbone, les bases ECOALIM v9 et GFLI 2.0. "
|
| 571 |
+
"Classification brut/transformé assistée par Mistral AI et le Catalogue UE des Matières Premières."
|
| 572 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py - Configuration et constantes pour l'application POC GAIA
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# ---------------------------------------------------------------------------
|
| 10 |
+
# Clé API Mistral
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
MISTRAL_API_KEY: str = os.getenv("MISTRAL_API_KEY", "")
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Chemins des fichiers de données (relatifs au dossier racine du projet)
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 18 |
+
|
| 19 |
+
ECOALIM_PATH = os.path.join(BASE_DIR, "BDD_ECOALIM_V9_vf2.xlsx")
|
| 20 |
+
GFLI_PATH = os.path.join(
|
| 21 |
+
BASE_DIR,
|
| 22 |
+
"Environmental impact GFLI 2.0 database - ReCiPE & EF3 method (Final 27-10-2022).xlsx",
|
| 23 |
+
)
|
| 24 |
+
PDF_CIR_PATH = os.path.join(
|
| 25 |
+
BASE_DIR,
|
| 26 |
+
"3644_Cir-1122_Nouveau Catalogue UE des Matières Premières_130722.pdf",
|
| 27 |
+
)
|
| 28 |
+
LOGIGRAMME_PATH = os.path.join(BASE_DIR, "logigramme.json")
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Noms de colonnes clés
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# EcoALIM (unité : kg CO2 eq / kg de produit)
|
| 34 |
+
ECOALIM_SHEET = "v9_ACV MP ECOALIM FR"
|
| 35 |
+
ECOALIM_HEADER_ROW = 1 # 0-indexed, row 1 = header
|
| 36 |
+
ECOALIM_COL_NOM = "Matières premières"
|
| 37 |
+
ECOALIM_COL_FAMILLE = "Familles des matières premières destinées aux animaux d'élevage"
|
| 38 |
+
ECOALIM_COL_PAYS_PROD = "Pays de la production primaire de la MP brute"
|
| 39 |
+
ECOALIM_COL_PAYS_TRANSFO = "Pays de la transformation"
|
| 40 |
+
ECOALIM_COL_PERIMETRE = "Périmètre"
|
| 41 |
+
ECOALIM_COL_CLIMATE = "Changement climatique - EF 3.1 (kg CO2 eq)"
|
| 42 |
+
|
| 43 |
+
# GFLI (unité : kg CO2 eq / tonne de produit)
|
| 44 |
+
GFLI_SHEET = "Economic allocation - EF3.1"
|
| 45 |
+
GFLI_COL_PRODUCT = "Products GFLI 2.0 database - economic allocation"
|
| 46 |
+
GFLI_COL_UNIT = "Unit"
|
| 47 |
+
GFLI_COL_CLIMATE = "Climate change (kg CO2 eq / ton product)"
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Mapping pays FR -> code ISO (pour GFLI qui utilise /XX dans le nom produit)
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
PAYS_FR_TO_ISO = {
|
| 53 |
+
"france": "FR",
|
| 54 |
+
"allemagne": "DE",
|
| 55 |
+
"belgique": "BE",
|
| 56 |
+
"pays-bas": "NL",
|
| 57 |
+
"espagne": "ES",
|
| 58 |
+
"italie": "IT",
|
| 59 |
+
"portugal": "PT",
|
| 60 |
+
"royaume-uni": "GB",
|
| 61 |
+
"angleterre": "GB",
|
| 62 |
+
"irlande": "IE",
|
| 63 |
+
"autriche": "AT",
|
| 64 |
+
"suisse": "CH",
|
| 65 |
+
"pologne": "PL",
|
| 66 |
+
"roumanie": "RO",
|
| 67 |
+
"bulgarie": "BG",
|
| 68 |
+
"hongrie": "HU",
|
| 69 |
+
"grèce": "GR",
|
| 70 |
+
"suède": "SE",
|
| 71 |
+
"finlande": "FI",
|
| 72 |
+
"danemark": "DK",
|
| 73 |
+
"norvège": "NO",
|
| 74 |
+
"république tchèque": "CZ",
|
| 75 |
+
"tchéquie": "CZ",
|
| 76 |
+
"slovaquie": "SK",
|
| 77 |
+
"slovénie": "SI",
|
| 78 |
+
"croatie": "HR",
|
| 79 |
+
"lituanie": "LT",
|
| 80 |
+
"lettonie": "LV",
|
| 81 |
+
"estonie": "EE",
|
| 82 |
+
"chypre": "CY",
|
| 83 |
+
"brésil": "BR",
|
| 84 |
+
"argentine": "AR",
|
| 85 |
+
"états-unis": "US",
|
| 86 |
+
"usa": "US",
|
| 87 |
+
"etats-unis": "US",
|
| 88 |
+
"canada": "CA",
|
| 89 |
+
"chine": "CN",
|
| 90 |
+
"inde": "IN",
|
| 91 |
+
"indonésie": "ID",
|
| 92 |
+
"thaïlande": "TH",
|
| 93 |
+
"vietnam": "VN",
|
| 94 |
+
"japon": "JP",
|
| 95 |
+
"australie": "AU",
|
| 96 |
+
"nouvelle-zélande": "NZ",
|
| 97 |
+
"russie": "RU",
|
| 98 |
+
"ukraine": "UA",
|
| 99 |
+
"turquie": "TR",
|
| 100 |
+
"mexique": "MX",
|
| 101 |
+
"colombie": "CO",
|
| 102 |
+
"pérou": "PE",
|
| 103 |
+
"chili": "CL",
|
| 104 |
+
"paraguay": "PY",
|
| 105 |
+
"afrique du sud": "ZA",
|
| 106 |
+
"égypte": "EG",
|
| 107 |
+
"éthiopie": "ET",
|
| 108 |
+
"sénégal": "SN",
|
| 109 |
+
"ouganda": "UG",
|
| 110 |
+
"cambodge": "KH",
|
| 111 |
+
"malaisie": "MY",
|
| 112 |
+
"pakistan": "PK",
|
| 113 |
+
"philippines": "PH",
|
| 114 |
+
"soudan": "SD",
|
| 115 |
+
"venezuela": "VE",
|
| 116 |
+
"corée du sud": "KR",
|
| 117 |
+
"biélorussie": "BY",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# Pays européens (pour décider RER vs GLO)
|
| 121 |
+
EUROPEAN_COUNTRIES_ISO = {
|
| 122 |
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
| 123 |
+
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
| 124 |
+
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "NO", "CH",
|
| 125 |
+
"UA", "BY", "BA",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
EUROPEAN_COUNTRIES_FR = {
|
| 129 |
+
"france", "allemagne", "belgique", "pays-bas", "espagne", "italie",
|
| 130 |
+
"portugal", "royaume-uni", "angleterre", "irlande", "autriche",
|
| 131 |
+
"suisse", "pologne", "roumanie", "bulgarie", "hongrie", "grèce",
|
| 132 |
+
"suède", "finlande", "danemark", "norvège", "république tchèque",
|
| 133 |
+
"tchéquie", "slovaquie", "slovénie", "croatie", "lituanie",
|
| 134 |
+
"lettonie", "estonie", "chypre", "ukraine", "biélorussie",
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
# Modèle Mistral à utiliser
|
| 138 |
+
MISTRAL_MODEL = "mistral-small-latest"
|
| 139 |
+
MISTRAL_MODEL_POWERFUL = "mistral-large-latest" # Pour analyses complexes (alternatives, tri)
|
data_loader.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
data_loader.py - Chargement et indexation des bases de données EcoALIM, GFLI et PDF CIR.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from typing import Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import pdfplumber
|
| 13 |
+
|
| 14 |
+
import config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ============================================================================
|
| 18 |
+
# EcoALIM
|
| 19 |
+
# ============================================================================
|
| 20 |
+
|
| 21 |
+
@lru_cache(maxsize=1)
|
| 22 |
+
def load_ecoalim() -> pd.DataFrame:
|
| 23 |
+
"""Charge la base EcoALIM (feuille FR) et renvoie un DataFrame nettoyé."""
|
| 24 |
+
df = pd.read_excel(
|
| 25 |
+
config.ECOALIM_PATH,
|
| 26 |
+
sheet_name=config.ECOALIM_SHEET,
|
| 27 |
+
header=config.ECOALIM_HEADER_ROW,
|
| 28 |
+
)
|
| 29 |
+
# Supprimer les lignes entièrement vides
|
| 30 |
+
df = df.dropna(subset=[config.ECOALIM_COL_NOM]).reset_index(drop=True)
|
| 31 |
+
# Normaliser les colonnes pays en minuscules pour faciliter la recherche
|
| 32 |
+
for col in [config.ECOALIM_COL_PAYS_PROD, config.ECOALIM_COL_PAYS_TRANSFO]:
|
| 33 |
+
if col in df.columns:
|
| 34 |
+
df[col] = df[col].astype(str).str.strip().str.lower()
|
| 35 |
+
return df
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _normalize_for_search(text: str) -> str:
|
| 39 |
+
"""Normalise un texte pour la recherche (accents, casse, ponctuation)."""
|
| 40 |
+
import unicodedata
|
| 41 |
+
text = text.lower().strip()
|
| 42 |
+
# Normalize unicode accents
|
| 43 |
+
nfkd = unicodedata.normalize('NFKD', text)
|
| 44 |
+
ascii_text = ''.join(c for c in nfkd if not unicodedata.combining(c))
|
| 45 |
+
return ascii_text
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
_STOPWORDS_FR = {
|
| 49 |
+
"de", "du", "des", "la", "le", "les", "d", "l", "a", "au", "aux"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _tokens_for_search(text: str) -> list[str]:
|
| 54 |
+
"""Découpe un texte en tokens utiles pour une recherche souple."""
|
| 55 |
+
text = _normalize_for_search(text)
|
| 56 |
+
tokens = re.findall(r"[a-z0-9]+", text)
|
| 57 |
+
return [t for t in tokens if t and t not in _STOPWORDS_FR]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def is_name_match(matiere: str, intrant_name: str) -> bool:
|
| 61 |
+
"""
|
| 62 |
+
Vérifie si le nom de la matière est une correspondance réelle (mot entier)
|
| 63 |
+
dans le nom de l'intrant, et non un simple sous-chaîne accidentelle.
|
| 64 |
+
Ex : "blé" ne matche PAS "blend", mais matche "Blé tendre".
|
| 65 |
+
"""
|
| 66 |
+
mat_norm = _normalize_for_search(matiere)
|
| 67 |
+
int_norm = _normalize_for_search(intrant_name)
|
| 68 |
+
|
| 69 |
+
if mat_norm == int_norm:
|
| 70 |
+
return True
|
| 71 |
+
|
| 72 |
+
# Le mot de la matière doit apparaître comme mot entier dans l'intrant
|
| 73 |
+
pattern = r'\b' + re.escape(mat_norm) + r'\b'
|
| 74 |
+
return bool(re.search(pattern, int_norm))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def search_ecoalim(
|
| 78 |
+
matiere: str,
|
| 79 |
+
pays_production: Optional[str] = None,
|
| 80 |
+
pays_transformation: Optional[str] = None,
|
| 81 |
+
) -> pd.DataFrame:
|
| 82 |
+
"""
|
| 83 |
+
Cherche dans EcoALIM les lignes correspondant à une matière première.
|
| 84 |
+
Utilise une recherche intelligente avec priorisation :
|
| 85 |
+
1. Nom commence par la matière
|
| 86 |
+
2. Mot entier trouvé dans le nom
|
| 87 |
+
3. Contient la matière (substring)
|
| 88 |
+
Retourne un DataFrame filtré et trié par pertinence (peut être vide).
|
| 89 |
+
"""
|
| 90 |
+
df = load_ecoalim()
|
| 91 |
+
matiere_norm = _normalize_for_search(matiere)
|
| 92 |
+
|
| 93 |
+
# Build normalized column for search
|
| 94 |
+
nom_col = config.ECOALIM_COL_NOM
|
| 95 |
+
df_norms = df[nom_col].apply(lambda x: _normalize_for_search(str(x)) if pd.notna(x) else "")
|
| 96 |
+
|
| 97 |
+
# Create priority masks
|
| 98 |
+
mask_starts = df_norms.str.startswith(matiere_norm, na=False)
|
| 99 |
+
pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
|
| 100 |
+
mask_word = df_norms.str.contains(pattern_word, na=False, regex=True)
|
| 101 |
+
tokens = _tokens_for_search(matiere_norm)
|
| 102 |
+
mask_tokens = pd.Series(False, index=df.index)
|
| 103 |
+
if tokens:
|
| 104 |
+
mask_tokens = df_norms.apply(
|
| 105 |
+
lambda x: all(t in _tokens_for_search(x) for t in tokens)
|
| 106 |
+
)
|
| 107 |
+
mask_contains = df_norms.str.contains(re.escape(matiere_norm), na=False)
|
| 108 |
+
|
| 109 |
+
# Use best available mask with priority
|
| 110 |
+
if mask_starts.any():
|
| 111 |
+
mask = mask_starts
|
| 112 |
+
elif mask_word.any():
|
| 113 |
+
mask = mask_word
|
| 114 |
+
elif mask_tokens.any():
|
| 115 |
+
mask = mask_tokens
|
| 116 |
+
elif mask_contains.any():
|
| 117 |
+
mask = mask_contains
|
| 118 |
+
else:
|
| 119 |
+
return pd.DataFrame(columns=df.columns)
|
| 120 |
+
|
| 121 |
+
if pays_production:
|
| 122 |
+
pays_prod_low = pays_production.lower().strip()
|
| 123 |
+
mask_pays = df[config.ECOALIM_COL_PAYS_PROD].str.contains(
|
| 124 |
+
re.escape(pays_prod_low), na=False
|
| 125 |
+
)
|
| 126 |
+
combined = mask & mask_pays
|
| 127 |
+
if combined.any():
|
| 128 |
+
mask = combined
|
| 129 |
+
|
| 130 |
+
if pays_transformation:
|
| 131 |
+
pays_transfo_low = pays_transformation.lower().strip()
|
| 132 |
+
mask_transfo = df[config.ECOALIM_COL_PAYS_TRANSFO].str.contains(
|
| 133 |
+
re.escape(pays_transfo_low), na=False
|
| 134 |
+
)
|
| 135 |
+
combined = mask & mask_transfo
|
| 136 |
+
if combined.any():
|
| 137 |
+
mask = combined
|
| 138 |
+
|
| 139 |
+
result = df[mask].copy()
|
| 140 |
+
|
| 141 |
+
# Sort by relevance: entries starting with the search term come first
|
| 142 |
+
if not result.empty:
|
| 143 |
+
result_norms = result[nom_col].apply(lambda x: _normalize_for_search(str(x)))
|
| 144 |
+
result["_priority"] = 3
|
| 145 |
+
result.loc[result_norms.str.contains(pattern_word, na=False, regex=True), "_priority"] = 1
|
| 146 |
+
result.loc[result_norms.str.startswith(matiere_norm, na=False), "_priority"] = 0
|
| 147 |
+
result.loc[result_norms.apply(lambda x: all(t in _tokens_for_search(x) for t in tokens)), "_priority"] = 2
|
| 148 |
+
# Prefer OS outputs over champ when ties exist
|
| 149 |
+
result["_os_priority"] = 1
|
| 150 |
+
result.loc[result_norms.str.contains("sortie os", na=False), "_os_priority"] = 0
|
| 151 |
+
result = result.sort_values(["_priority", "_os_priority"]).drop(columns=["_priority", "_os_priority"])
|
| 152 |
+
|
| 153 |
+
return result
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def get_ecoalim_climate_value(
|
| 157 |
+
matiere: str,
|
| 158 |
+
pays_production: Optional[str] = None,
|
| 159 |
+
pays_transformation: Optional[str] = None,
|
| 160 |
+
) -> Optional[Tuple[float, str, str]]:
|
| 161 |
+
"""
|
| 162 |
+
Retourne (valeur_kg_co2_eq, nom_intrant, source_info) ou None.
|
| 163 |
+
Unité EcoALIM : kg CO2 eq / kg de produit.
|
| 164 |
+
"""
|
| 165 |
+
results = search_ecoalim(matiere, pays_production, pays_transformation)
|
| 166 |
+
if results.empty:
|
| 167 |
+
return None
|
| 168 |
+
# Prendre la première correspondance (ou la plus défavorable si demandé)
|
| 169 |
+
row = results.iloc[0]
|
| 170 |
+
val = row.get(config.ECOALIM_COL_CLIMATE)
|
| 171 |
+
if pd.isna(val):
|
| 172 |
+
return None
|
| 173 |
+
nom = row.get(config.ECOALIM_COL_NOM, matiere)
|
| 174 |
+
return (float(val), str(nom), "ECOALIM")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def get_ecoalim_worst_value(matiere: str) -> Optional[Tuple[float, str, str]]:
|
| 178 |
+
"""Retourne la valeur la plus défavorable (max) pour cette matière dans EcoALIM."""
|
| 179 |
+
results = search_ecoalim(matiere)
|
| 180 |
+
if results.empty:
|
| 181 |
+
return None
|
| 182 |
+
climate_col = config.ECOALIM_COL_CLIMATE
|
| 183 |
+
results_valid = results.dropna(subset=[climate_col])
|
| 184 |
+
if results_valid.empty:
|
| 185 |
+
return None
|
| 186 |
+
idx = results_valid[climate_col].idxmax()
|
| 187 |
+
row = results_valid.loc[idx]
|
| 188 |
+
return (float(row[climate_col]), str(row[config.ECOALIM_COL_NOM]), "ECOALIM (valeur la plus défavorable)")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ============================================================================
|
| 192 |
+
# GFLI
|
| 193 |
+
# ============================================================================
|
| 194 |
+
|
| 195 |
+
@lru_cache(maxsize=1)
|
| 196 |
+
def load_gfli() -> pd.DataFrame:
|
| 197 |
+
"""Charge la base GFLI (Economic allocation EF3.1)."""
|
| 198 |
+
df = pd.read_excel(
|
| 199 |
+
config.GFLI_PATH,
|
| 200 |
+
sheet_name=config.GFLI_SHEET,
|
| 201 |
+
header=0,
|
| 202 |
+
)
|
| 203 |
+
df = df.dropna(subset=[config.GFLI_COL_PRODUCT]).reset_index(drop=True)
|
| 204 |
+
return df
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _extract_gfli_country(product_name: str) -> Optional[str]:
|
| 208 |
+
"""Extrait le code pays ISO d'un nom de produit GFLI (ex: '.../FR Economic S' -> 'FR')."""
|
| 209 |
+
m = re.search(r"/([A-Z]{2,3})\s+Economic\s+S", product_name)
|
| 210 |
+
return m.group(1) if m else None
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _extract_gfli_base_name(product_name: str) -> str:
|
| 214 |
+
"""Extrait le nom de base du produit GFLI (sans le code pays)."""
|
| 215 |
+
m = re.match(r"(.+)/[A-Z]{2,3}\s+Economic\s+S", product_name)
|
| 216 |
+
return m.group(1).strip() if m else product_name.strip()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def search_gfli(
|
| 220 |
+
matiere: str,
|
| 221 |
+
country_iso: Optional[str] = None,
|
| 222 |
+
) -> pd.DataFrame:
|
| 223 |
+
"""
|
| 224 |
+
Recherche dans GFLI par nom de matière (en anglais) et optionnellement par pays ISO.
|
| 225 |
+
Uses word-boundary matching for better precision.
|
| 226 |
+
"""
|
| 227 |
+
df = load_gfli()
|
| 228 |
+
matiere_norm = _normalize_for_search(matiere)
|
| 229 |
+
|
| 230 |
+
prod_col = config.GFLI_COL_PRODUCT
|
| 231 |
+
df_norms = df[prod_col].apply(lambda x: _normalize_for_search(str(x)) if pd.notna(x) else "")
|
| 232 |
+
|
| 233 |
+
# Strategy 1: starts-with
|
| 234 |
+
mask = df_norms.str.startswith(matiere_norm, na=False)
|
| 235 |
+
|
| 236 |
+
# Strategy 2: word-boundary match
|
| 237 |
+
if not mask.any():
|
| 238 |
+
pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
|
| 239 |
+
mask = df_norms.str.contains(pattern_word, na=False, regex=True)
|
| 240 |
+
|
| 241 |
+
# Strategy 3: token-subset match (souple)
|
| 242 |
+
if not mask.any():
|
| 243 |
+
tokens = _tokens_for_search(matiere_norm)
|
| 244 |
+
if tokens:
|
| 245 |
+
mask = df_norms.apply(lambda x: all(t in _tokens_for_search(x) for t in tokens))
|
| 246 |
+
|
| 247 |
+
# Strategy 4: contains
|
| 248 |
+
if not mask.any():
|
| 249 |
+
mask = df_norms.str.contains(re.escape(matiere_norm), na=False)
|
| 250 |
+
|
| 251 |
+
if country_iso:
|
| 252 |
+
country_upper = country_iso.upper().strip()
|
| 253 |
+
mask_country = df[prod_col].str.contains(
|
| 254 |
+
rf"/{re.escape(country_upper)}\s+Economic\s+S", na=False, regex=True
|
| 255 |
+
)
|
| 256 |
+
combined = mask & mask_country
|
| 257 |
+
if combined.any():
|
| 258 |
+
mask = combined
|
| 259 |
+
|
| 260 |
+
return df[mask].copy()
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def get_gfli_climate_value(
|
| 264 |
+
matiere: str,
|
| 265 |
+
country_iso: Optional[str] = None,
|
| 266 |
+
) -> Optional[Tuple[float, str, str]]:
|
| 267 |
+
"""
|
| 268 |
+
Retourne (valeur_kg_co2_eq_par_tonne, nom_produit, source_info) ou None.
|
| 269 |
+
Unité GFLI : kg CO2 eq / tonne de produit.
|
| 270 |
+
"""
|
| 271 |
+
results = search_gfli(matiere, country_iso)
|
| 272 |
+
if results.empty:
|
| 273 |
+
return None
|
| 274 |
+
row = results.iloc[0]
|
| 275 |
+
val = row.get(config.GFLI_COL_CLIMATE)
|
| 276 |
+
if pd.isna(val):
|
| 277 |
+
return None
|
| 278 |
+
nom = row.get(config.GFLI_COL_PRODUCT, matiere)
|
| 279 |
+
return (float(val), str(nom), "GFLI")
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def get_gfli_worst_value(matiere: str) -> Optional[Tuple[float, str, str]]:
|
| 283 |
+
"""Retourne la valeur la plus défavorable (max) pour cette matière dans GFLI."""
|
| 284 |
+
results = search_gfli(matiere)
|
| 285 |
+
if results.empty:
|
| 286 |
+
return None
|
| 287 |
+
climate_col = config.GFLI_COL_CLIMATE
|
| 288 |
+
results_valid = results.dropna(subset=[climate_col])
|
| 289 |
+
if results_valid.empty:
|
| 290 |
+
return None
|
| 291 |
+
idx = results_valid[climate_col].idxmax()
|
| 292 |
+
row = results_valid.loc[idx]
|
| 293 |
+
return (float(row[climate_col]), str(row[config.GFLI_COL_PRODUCT]), "GFLI (valeur la plus défavorable)")
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def get_gfli_rer_value(matiere: str) -> Optional[Tuple[float, str, str]]:
|
| 297 |
+
"""Retourne la valeur Mix Européen (RER) dans GFLI."""
|
| 298 |
+
return get_gfli_climate_value(matiere, "RER")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def get_gfli_glo_value(matiere: str) -> Optional[Tuple[float, str, str]]:
|
| 302 |
+
"""Retourne la valeur Mix Monde (GLO) dans GFLI."""
|
| 303 |
+
return get_gfli_climate_value(matiere, "GLO")
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ============================================================================
|
| 307 |
+
# GFLI - Listes utilitaires
|
| 308 |
+
# ============================================================================
|
| 309 |
+
|
| 310 |
+
def get_gfli_base_products() -> List[str]:
|
| 311 |
+
"""Retourne la liste des noms de base de produits uniques dans GFLI."""
|
| 312 |
+
df = load_gfli()
|
| 313 |
+
products = df[config.GFLI_COL_PRODUCT].dropna().unique()
|
| 314 |
+
base_names = set()
|
| 315 |
+
for p in products:
|
| 316 |
+
base_names.add(_extract_gfli_base_name(str(p)))
|
| 317 |
+
return sorted(base_names)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def get_ecoalim_matieres() -> List[str]:
|
| 321 |
+
"""Retourne la liste des matières premières uniques dans EcoALIM."""
|
| 322 |
+
df = load_ecoalim()
|
| 323 |
+
return sorted(df[config.ECOALIM_COL_NOM].dropna().unique().tolist())
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ============================================================================
|
| 327 |
+
# Fonctions multi-candidats (pour affichage comparatif)
|
| 328 |
+
# ============================================================================
|
| 329 |
+
|
| 330 |
+
def get_top_ecoalim_candidates(
|
| 331 |
+
matiere: str,
|
| 332 |
+
pays_production: Optional[str] = None,
|
| 333 |
+
pays_transformation: Optional[str] = None,
|
| 334 |
+
top_n: Optional[int] = 8,
|
| 335 |
+
) -> List[Dict]:
|
| 336 |
+
"""
|
| 337 |
+
Retourne les top N correspondances EcoALIM triées par pertinence,
|
| 338 |
+
chacune avec nom + valeur impact.
|
| 339 |
+
"""
|
| 340 |
+
results = search_ecoalim(matiere, pays_production, pays_transformation)
|
| 341 |
+
if results.empty:
|
| 342 |
+
return []
|
| 343 |
+
candidates = []
|
| 344 |
+
rows = results if top_n is None else results.head(top_n)
|
| 345 |
+
for _, row in rows.iterrows():
|
| 346 |
+
val = row.get(config.ECOALIM_COL_CLIMATE)
|
| 347 |
+
if pd.notna(val):
|
| 348 |
+
candidates.append({
|
| 349 |
+
"nom": str(row[config.ECOALIM_COL_NOM]),
|
| 350 |
+
"impact": float(val),
|
| 351 |
+
"unite": "kg CO2 eq / kg",
|
| 352 |
+
"source": "ECOALIM",
|
| 353 |
+
})
|
| 354 |
+
return candidates
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def get_top_gfli_candidates(
|
| 358 |
+
matiere: str,
|
| 359 |
+
country_iso: Optional[str] = None,
|
| 360 |
+
top_n: Optional[int] = 8,
|
| 361 |
+
) -> List[Dict]:
|
| 362 |
+
"""
|
| 363 |
+
Retourne les top N correspondances GFLI triées par pertinence,
|
| 364 |
+
chacune avec nom + valeur impact.
|
| 365 |
+
"""
|
| 366 |
+
results = search_gfli(matiere, country_iso)
|
| 367 |
+
if results.empty:
|
| 368 |
+
return []
|
| 369 |
+
candidates = []
|
| 370 |
+
rows = results if top_n is None else results.head(top_n)
|
| 371 |
+
for _, row in rows.iterrows():
|
| 372 |
+
val = row.get(config.GFLI_COL_CLIMATE)
|
| 373 |
+
if pd.notna(val):
|
| 374 |
+
candidates.append({
|
| 375 |
+
"nom": str(row[config.GFLI_COL_PRODUCT]),
|
| 376 |
+
"impact": float(val),
|
| 377 |
+
"unite": "kg CO2 eq / tonne",
|
| 378 |
+
"source": "GFLI",
|
| 379 |
+
})
|
| 380 |
+
return candidates
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# ============================================================================
|
| 384 |
+
# PDF CIR - Catalogue des Matières Premières
|
| 385 |
+
# ============================================================================
|
| 386 |
+
|
| 387 |
+
@lru_cache(maxsize=1)
|
| 388 |
+
def load_pdf_text() -> str:
|
| 389 |
+
"""Charge et retourne le texte complet du PDF CIR."""
|
| 390 |
+
full_text = []
|
| 391 |
+
with pdfplumber.open(config.PDF_CIR_PATH) as pdf:
|
| 392 |
+
for page in pdf.pages:
|
| 393 |
+
text = page.extract_text()
|
| 394 |
+
if text:
|
| 395 |
+
full_text.append(text)
|
| 396 |
+
return "\n\n".join(full_text)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def get_pdf_excerpt(max_chars: int = 15000) -> str:
|
| 400 |
+
"""Retourne un extrait du PDF CIR (tronqué si nécessaire) pour envoi au LLM."""
|
| 401 |
+
text = load_pdf_text()
|
| 402 |
+
if len(text) > max_chars:
|
| 403 |
+
return text[:max_chars] + "\n... [texte tronqué]"
|
| 404 |
+
return text
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ============================================================================
|
| 408 |
+
# Logigramme
|
| 409 |
+
# ============================================================================
|
| 410 |
+
|
| 411 |
+
@lru_cache(maxsize=1)
|
| 412 |
+
def load_logigramme() -> dict:
|
| 413 |
+
"""Charge le logigramme JSON."""
|
| 414 |
+
with open(config.LOGIGRAMME_PATH, "r", encoding="utf-8") as f:
|
| 415 |
+
return json.load(f)
|
flowchart_engine.py
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
flowchart_engine.py - Moteur du logigramme d'aide à la détermination de l'impact carbone.
|
| 3 |
+
|
| 4 |
+
Suit le logigramme JSON pour déterminer quelle valeur d'impact carbone utiliser
|
| 5 |
+
en fonction de la provenance, du niveau de transformation, et des données disponibles.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
import config
|
| 13 |
+
import data_loader
|
| 14 |
+
import llm_service
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class StepLog:
|
| 19 |
+
"""Un pas dans le parcours du logigramme."""
|
| 20 |
+
node_id: str
|
| 21 |
+
question: Optional[str]
|
| 22 |
+
answer: Optional[str]
|
| 23 |
+
action: Optional[str] = None
|
| 24 |
+
result: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class CarbonResult:
|
| 29 |
+
"""Résultat complet de l'évaluation carbone d'une matière première."""
|
| 30 |
+
matiere_premiere: str
|
| 31 |
+
pays_production: Optional[str]
|
| 32 |
+
pays_transformation: Optional[str]
|
| 33 |
+
classification: str # "brut" | "transforme"
|
| 34 |
+
classification_justification: str
|
| 35 |
+
|
| 36 |
+
# Valeur finale
|
| 37 |
+
impact_kg_co2_eq: Optional[float] = None
|
| 38 |
+
impact_tonne_co2_eq: Optional[float] = None # conversion en tonnes
|
| 39 |
+
unite_source: str = "" # "kg CO2 eq / kg" ou "kg CO2 eq / tonne"
|
| 40 |
+
|
| 41 |
+
# Traçabilité
|
| 42 |
+
source_db: str = "" # "ECOALIM" | "GFLI"
|
| 43 |
+
intrant_utilise: str = "" # nom exact dans la BDD
|
| 44 |
+
match_exact: bool = True
|
| 45 |
+
justification_alternative: Optional[str] = None
|
| 46 |
+
|
| 47 |
+
# Parcours de logique
|
| 48 |
+
parcours: List[StepLog] = field(default_factory=list)
|
| 49 |
+
node_resultat: str = "" # node_id du résultat
|
| 50 |
+
actions_appliquees: List[str] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
# Candidats alternatifs (pour affichage comparatif quand match non exact)
|
| 53 |
+
candidats_alternatifs: List[dict] = field(default_factory=list)
|
| 54 |
+
candidat_recommande: Optional[str] = None
|
| 55 |
+
candidats_reflexion: Optional[str] = None
|
| 56 |
+
|
| 57 |
+
# 4 propositions d'alternatives (itinerary, locality, form, combined)
|
| 58 |
+
alternatives_itinerary: Optional[dict] = None
|
| 59 |
+
alternatives_locality: Optional[dict] = None
|
| 60 |
+
alternatives_form: Optional[dict] = None
|
| 61 |
+
alternatives_combined: Optional[dict] = None
|
| 62 |
+
|
| 63 |
+
erreur: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _is_france(pays: Optional[str]) -> bool:
|
| 67 |
+
"""Vérifie si le pays est la France."""
|
| 68 |
+
if not pays:
|
| 69 |
+
return False
|
| 70 |
+
return pays.lower().strip() in ("france", "fr")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _is_european(pays: Optional[str]) -> bool:
|
| 74 |
+
"""Vérifie si le pays est européen."""
|
| 75 |
+
if not pays:
|
| 76 |
+
return False
|
| 77 |
+
pays_low = pays.lower().strip()
|
| 78 |
+
if pays_low in config.EUROPEAN_COUNTRIES_FR:
|
| 79 |
+
return True
|
| 80 |
+
pays_iso = config.PAYS_FR_TO_ISO.get(pays_low, "").upper()
|
| 81 |
+
return pays_iso in config.EUROPEAN_COUNTRIES_ISO
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _get_country_iso(pays: Optional[str]) -> Optional[str]:
|
| 85 |
+
"""Convertit un nom de pays FR en code ISO."""
|
| 86 |
+
if not pays:
|
| 87 |
+
return None
|
| 88 |
+
return config.PAYS_FR_TO_ISO.get(pays.lower().strip())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _is_name_match(matiere: str, intrant_name: str) -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Vérifie si le nom de la matière est une correspondance réelle (mot entier)
|
| 94 |
+
dans le nom de l'intrant, et non un simple sous-chaîne accidentelle.
|
| 95 |
+
Délègue à data_loader.is_name_match.
|
| 96 |
+
"""
|
| 97 |
+
return data_loader.is_name_match(matiere, intrant_name)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ============================================================================
|
| 101 |
+
# Fonctions de résolution par node de résultat
|
| 102 |
+
# ============================================================================
|
| 103 |
+
|
| 104 |
+
def _resolve_node_4(matiere: str, result: CarbonResult) -> CarbonResult:
|
| 105 |
+
"""
|
| 106 |
+
Node 4 : Provenance inconnue + intrant brut.
|
| 107 |
+
1. Valeur la plus défavorable dans GFLI
|
| 108 |
+
2. Sinon la plus défavorable dans ECOALIM
|
| 109 |
+
3. Sinon valeur GFLI de l'intrant au schéma cultural le plus proche (LLM)
|
| 110 |
+
"""
|
| 111 |
+
# Étape 1 : GFLI worst
|
| 112 |
+
result.actions_appliquees.append("1. Recherche de la valeur la plus défavorable dans GFLI")
|
| 113 |
+
gfli_worst = data_loader.get_gfli_worst_value(matiere)
|
| 114 |
+
|
| 115 |
+
# Rejeter les faux positifs (ex : "blé" → "blend")
|
| 116 |
+
if gfli_worst and not _is_name_match(matiere, gfli_worst[1]):
|
| 117 |
+
result.actions_appliquees.append(f" ⚠ Faux positif rejeté : {gfli_worst[1]}")
|
| 118 |
+
gfli_worst = None
|
| 119 |
+
|
| 120 |
+
llm_justification = None
|
| 121 |
+
llm_match_exact = None
|
| 122 |
+
if not gfli_worst:
|
| 123 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 124 |
+
if gfli_smart and "valeur_kg_co2_eq_par_tonne" in gfli_smart:
|
| 125 |
+
llm_match_exact = gfli_smart.get("match_exact", False)
|
| 126 |
+
llm_justification = gfli_smart.get("justification")
|
| 127 |
+
base_name = gfli_smart["nom_intrant"].split(",")[0].split("/")[0].strip()
|
| 128 |
+
gfli_worst = data_loader.get_gfli_worst_value(base_name)
|
| 129 |
+
if not gfli_worst:
|
| 130 |
+
# Utiliser directement la valeur du LLM
|
| 131 |
+
gfli_worst = (
|
| 132 |
+
gfli_smart["valeur_kg_co2_eq_par_tonne"],
|
| 133 |
+
gfli_smart["nom_intrant"],
|
| 134 |
+
gfli_smart.get("source", "GFLI"),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
if gfli_worst:
|
| 138 |
+
val, nom, src = gfli_worst
|
| 139 |
+
result.impact_kg_co2_eq = val
|
| 140 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 141 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 142 |
+
result.source_db = src
|
| 143 |
+
result.intrant_utilise = nom
|
| 144 |
+
# Déterminer si le match est exact
|
| 145 |
+
if llm_match_exact is not None:
|
| 146 |
+
result.match_exact = llm_match_exact
|
| 147 |
+
else:
|
| 148 |
+
result.match_exact = _is_name_match(matiere, nom)
|
| 149 |
+
if llm_justification:
|
| 150 |
+
result.justification_alternative = llm_justification
|
| 151 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {nom} = {val:.2f} kg CO2 eq/t")
|
| 152 |
+
return result
|
| 153 |
+
|
| 154 |
+
# Étape 2 : ECOALIM worst
|
| 155 |
+
result.actions_appliquees.append("2. Recherche de la valeur la plus défavorable dans ECOALIM")
|
| 156 |
+
eco_worst = data_loader.get_ecoalim_worst_value(matiere)
|
| 157 |
+
|
| 158 |
+
# Rejeter les faux positifs
|
| 159 |
+
if eco_worst and not _is_name_match(matiere, eco_worst[1]):
|
| 160 |
+
result.actions_appliquees.append(f" ⚠ Faux positif rejeté : {eco_worst[1]}")
|
| 161 |
+
eco_worst = None
|
| 162 |
+
|
| 163 |
+
llm_justification_eco = None
|
| 164 |
+
llm_match_exact_eco = None
|
| 165 |
+
if not eco_worst:
|
| 166 |
+
eco_smart = llm_service.smart_search_ecoalim(matiere)
|
| 167 |
+
if eco_smart:
|
| 168 |
+
llm_match_exact_eco = eco_smart.get("match_exact", False)
|
| 169 |
+
llm_justification_eco = eco_smart.get("justification")
|
| 170 |
+
eco_worst = data_loader.get_ecoalim_worst_value(
|
| 171 |
+
eco_smart["nom_intrant"].split(",")[0].strip()
|
| 172 |
+
)
|
| 173 |
+
if not eco_worst:
|
| 174 |
+
eco_worst = (
|
| 175 |
+
eco_smart["valeur_kg_co2_eq"],
|
| 176 |
+
eco_smart["nom_intrant"],
|
| 177 |
+
eco_smart.get("source", "ECOALIM"),
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
if eco_worst:
|
| 181 |
+
val, nom, src = eco_worst
|
| 182 |
+
result.impact_kg_co2_eq = val
|
| 183 |
+
result.impact_tonne_co2_eq = val
|
| 184 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 185 |
+
result.source_db = src
|
| 186 |
+
result.intrant_utilise = nom
|
| 187 |
+
if llm_match_exact_eco is not None:
|
| 188 |
+
result.match_exact = llm_match_exact_eco
|
| 189 |
+
else:
|
| 190 |
+
result.match_exact = _is_name_match(matiere, nom)
|
| 191 |
+
if llm_justification_eco:
|
| 192 |
+
result.justification_alternative = llm_justification_eco
|
| 193 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {nom} = {val:.4f} kg CO2 eq/kg")
|
| 194 |
+
return result
|
| 195 |
+
|
| 196 |
+
# Étape 3 : LLM pour trouver le schéma cultural le plus proche
|
| 197 |
+
result.actions_appliquees.append("3. Recherche via LLM de l'intrant au schéma cultural le plus proche (GFLI)")
|
| 198 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 199 |
+
if gfli_smart and "valeur_kg_co2_eq_par_tonne" in gfli_smart:
|
| 200 |
+
val = gfli_smart["valeur_kg_co2_eq_par_tonne"]
|
| 201 |
+
result.impact_kg_co2_eq = val
|
| 202 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 203 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 204 |
+
result.source_db = gfli_smart["source"]
|
| 205 |
+
result.intrant_utilise = gfli_smart["nom_intrant"]
|
| 206 |
+
result.match_exact = gfli_smart.get("match_exact", False)
|
| 207 |
+
result.justification_alternative = gfli_smart.get("justification")
|
| 208 |
+
result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']} = {val:.2f} kg CO2 eq/t")
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
# Étape 4 : Fallback - Proposer des matières alternatives
|
| 212 |
+
result.actions_appliquees.append("4. Fallback - Recherche via LLM de 4 alternatives")
|
| 213 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI")
|
| 214 |
+
|
| 215 |
+
if alternatives:
|
| 216 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 217 |
+
if alternatives.get("itinerary"):
|
| 218 |
+
alt = alternatives["itinerary"]
|
| 219 |
+
result.alternatives_itinerary = {
|
| 220 |
+
"name": alt["name"],
|
| 221 |
+
"impact": alt["impact"],
|
| 222 |
+
"source": alt["source"],
|
| 223 |
+
"reasoning": alt["reasoning"],
|
| 224 |
+
}
|
| 225 |
+
if alternatives.get("locality"):
|
| 226 |
+
alt = alternatives["locality"]
|
| 227 |
+
result.alternatives_locality = {
|
| 228 |
+
"name": alt["name"],
|
| 229 |
+
"impact": alt["impact"],
|
| 230 |
+
"source": alt["source"],
|
| 231 |
+
"reasoning": alt["reasoning"],
|
| 232 |
+
}
|
| 233 |
+
if alternatives.get("form"):
|
| 234 |
+
alt = alternatives["form"]
|
| 235 |
+
result.alternatives_form = {
|
| 236 |
+
"name": alt["name"],
|
| 237 |
+
"impact": alt["impact"],
|
| 238 |
+
"source": alt["source"],
|
| 239 |
+
"reasoning": alt["reasoning"],
|
| 240 |
+
}
|
| 241 |
+
if alternatives.get("combined"):
|
| 242 |
+
alt = alternatives["combined"]
|
| 243 |
+
result.alternatives_combined = {
|
| 244 |
+
"name": alt["name"],
|
| 245 |
+
"impact": alt["impact"],
|
| 246 |
+
"source": alt["source"],
|
| 247 |
+
"reasoning": alt["reasoning"],
|
| 248 |
+
}
|
| 249 |
+
# Utiliser la combined comme valeur principale
|
| 250 |
+
val = alt["impact"]
|
| 251 |
+
result.impact_kg_co2_eq = val
|
| 252 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 253 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 254 |
+
result.source_db = alt["source"]
|
| 255 |
+
result.intrant_utilise = alt["name"]
|
| 256 |
+
result.match_exact = False
|
| 257 |
+
result.justification_alternative = alt["reasoning"]
|
| 258 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 259 |
+
return result
|
| 260 |
+
|
| 261 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' dans GFLI ni ECOALIM."
|
| 262 |
+
return result
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _resolve_node_5(matiere: str, result: CarbonResult) -> CarbonResult:
|
| 266 |
+
"""
|
| 267 |
+
Node 5 : Provenance inconnue + intrant transformé.
|
| 268 |
+
Mêmes étapes que node_4 mais pour un intrant transformé.
|
| 269 |
+
"""
|
| 270 |
+
# Étape 1 : GFLI worst
|
| 271 |
+
result.actions_appliquees.append("1. Recherche de la valeur la plus défavorable pour l'intrant transformé dans GFLI")
|
| 272 |
+
gfli_worst = data_loader.get_gfli_worst_value(matiere)
|
| 273 |
+
|
| 274 |
+
# Rejeter les faux positifs
|
| 275 |
+
if gfli_worst and not _is_name_match(matiere, gfli_worst[1]):
|
| 276 |
+
result.actions_appliquees.append(f" ⚠ Faux positif rejeté : {gfli_worst[1]}")
|
| 277 |
+
gfli_worst = None
|
| 278 |
+
|
| 279 |
+
llm_justification = None
|
| 280 |
+
llm_match_exact = None
|
| 281 |
+
if not gfli_worst:
|
| 282 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 283 |
+
if gfli_smart and "valeur_kg_co2_eq_par_tonne" in gfli_smart:
|
| 284 |
+
llm_match_exact = gfli_smart.get("match_exact", False)
|
| 285 |
+
llm_justification = gfli_smart.get("justification")
|
| 286 |
+
base_name = gfli_smart["nom_intrant"].split(",")[0].split("/")[0].strip()
|
| 287 |
+
gfli_worst = data_loader.get_gfli_worst_value(base_name)
|
| 288 |
+
if not gfli_worst:
|
| 289 |
+
gfli_worst = (
|
| 290 |
+
gfli_smart["valeur_kg_co2_eq_par_tonne"],
|
| 291 |
+
gfli_smart["nom_intrant"],
|
| 292 |
+
gfli_smart.get("source", "GFLI"),
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
if gfli_worst:
|
| 296 |
+
val, nom, src = gfli_worst
|
| 297 |
+
result.impact_kg_co2_eq = val
|
| 298 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 299 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 300 |
+
result.source_db = src
|
| 301 |
+
result.intrant_utilise = nom
|
| 302 |
+
if llm_match_exact is not None:
|
| 303 |
+
result.match_exact = llm_match_exact
|
| 304 |
+
else:
|
| 305 |
+
result.match_exact = _is_name_match(matiere, nom)
|
| 306 |
+
if llm_justification:
|
| 307 |
+
result.justification_alternative = llm_justification
|
| 308 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {nom} = {val:.2f} kg CO2 eq/t")
|
| 309 |
+
return result
|
| 310 |
+
|
| 311 |
+
# Étape 2 : ECOALIM worst
|
| 312 |
+
result.actions_appliquees.append("2. Recherche de la valeur la plus défavorable dans ECOALIM")
|
| 313 |
+
eco_worst = data_loader.get_ecoalim_worst_value(matiere)
|
| 314 |
+
|
| 315 |
+
if eco_worst and not _is_name_match(matiere, eco_worst[1]):
|
| 316 |
+
result.actions_appliquees.append(f" ⚠ Faux positif rejeté : {eco_worst[1]}")
|
| 317 |
+
eco_worst = None
|
| 318 |
+
|
| 319 |
+
llm_justification_eco = None
|
| 320 |
+
llm_match_exact_eco = None
|
| 321 |
+
if not eco_worst:
|
| 322 |
+
eco_smart = llm_service.smart_search_ecoalim(matiere)
|
| 323 |
+
if eco_smart:
|
| 324 |
+
llm_match_exact_eco = eco_smart.get("match_exact", False)
|
| 325 |
+
llm_justification_eco = eco_smart.get("justification")
|
| 326 |
+
eco_worst = data_loader.get_ecoalim_worst_value(
|
| 327 |
+
eco_smart["nom_intrant"].split(",")[0].strip()
|
| 328 |
+
)
|
| 329 |
+
if not eco_worst:
|
| 330 |
+
eco_worst = (
|
| 331 |
+
eco_smart["valeur_kg_co2_eq"],
|
| 332 |
+
eco_smart["nom_intrant"],
|
| 333 |
+
eco_smart.get("source", "ECOALIM"),
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
if eco_worst:
|
| 337 |
+
val, nom, src = eco_worst
|
| 338 |
+
result.impact_kg_co2_eq = val
|
| 339 |
+
result.impact_tonne_co2_eq = val
|
| 340 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 341 |
+
result.source_db = src
|
| 342 |
+
result.intrant_utilise = nom
|
| 343 |
+
if llm_match_exact_eco is not None:
|
| 344 |
+
result.match_exact = llm_match_exact_eco
|
| 345 |
+
else:
|
| 346 |
+
result.match_exact = _is_name_match(matiere, nom)
|
| 347 |
+
if llm_justification_eco:
|
| 348 |
+
result.justification_alternative = llm_justification_eco
|
| 349 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {nom} = {val:.4f} kg CO2 eq/kg")
|
| 350 |
+
return result
|
| 351 |
+
|
| 352 |
+
# Étape 3 : LLM
|
| 353 |
+
result.actions_appliquees.append("3. Recherche via LLM de l'intrant transformé au process le plus proche (GFLI)")
|
| 354 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 355 |
+
if gfli_smart and "valeur_kg_co2_eq_par_tonne" in gfli_smart:
|
| 356 |
+
val = gfli_smart["valeur_kg_co2_eq_par_tonne"]
|
| 357 |
+
result.impact_kg_co2_eq = val
|
| 358 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 359 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 360 |
+
result.source_db = gfli_smart["source"]
|
| 361 |
+
result.intrant_utilise = gfli_smart["nom_intrant"]
|
| 362 |
+
result.match_exact = gfli_smart.get("match_exact", False)
|
| 363 |
+
result.justification_alternative = gfli_smart.get("justification")
|
| 364 |
+
result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
|
| 365 |
+
return result
|
| 366 |
+
|
| 367 |
+
# Étape 4 : Fallback - Proposer des matières alternatives
|
| 368 |
+
result.actions_appliquees.append("4. Fallback - Recherche via LLM de 4 alternatives (transformée)")
|
| 369 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI")
|
| 370 |
+
|
| 371 |
+
if alternatives:
|
| 372 |
+
# Stocker les 4 alternatives
|
| 373 |
+
if alternatives.get("itinerary"):
|
| 374 |
+
alt = alternatives["itinerary"]
|
| 375 |
+
result.alternatives_itinerary = {
|
| 376 |
+
"name": alt["name"],
|
| 377 |
+
"impact": alt["impact"],
|
| 378 |
+
"source": alt["source"],
|
| 379 |
+
"reasoning": alt["reasoning"],
|
| 380 |
+
}
|
| 381 |
+
if alternatives.get("locality"):
|
| 382 |
+
alt = alternatives["locality"]
|
| 383 |
+
result.alternatives_locality = {
|
| 384 |
+
"name": alt["name"],
|
| 385 |
+
"impact": alt["impact"],
|
| 386 |
+
"source": alt["source"],
|
| 387 |
+
"reasoning": alt["reasoning"],
|
| 388 |
+
}
|
| 389 |
+
if alternatives.get("form"):
|
| 390 |
+
alt = alternatives["form"]
|
| 391 |
+
result.alternatives_form = {
|
| 392 |
+
"name": alt["name"],
|
| 393 |
+
"impact": alt["impact"],
|
| 394 |
+
"source": alt["source"],
|
| 395 |
+
"reasoning": alt["reasoning"],
|
| 396 |
+
}
|
| 397 |
+
if alternatives.get("combined"):
|
| 398 |
+
alt = alternatives["combined"]
|
| 399 |
+
result.alternatives_combined = {
|
| 400 |
+
"name": alt["name"],
|
| 401 |
+
"impact": alt["impact"],
|
| 402 |
+
"source": alt["source"],
|
| 403 |
+
"reasoning": alt["reasoning"],
|
| 404 |
+
}
|
| 405 |
+
# Utiliser la combined comme valeur principale
|
| 406 |
+
val = alt["impact"]
|
| 407 |
+
result.impact_kg_co2_eq = val
|
| 408 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 409 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 410 |
+
result.source_db = alt["source"]
|
| 411 |
+
result.intrant_utilise = alt["name"]
|
| 412 |
+
result.match_exact = False
|
| 413 |
+
result.justification_alternative = alt["reasoning"]
|
| 414 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 415 |
+
return result
|
| 416 |
+
|
| 417 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé, provenance inconnue)."
|
| 418 |
+
return result
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _resolve_node_8(matiere: str, result: CarbonResult) -> CarbonResult:
|
| 422 |
+
"""
|
| 423 |
+
Node 8 : Provenance connue + brut + cultivé en France.
|
| 424 |
+
1. EcoALIM
|
| 425 |
+
2. GFLI
|
| 426 |
+
3. Intrant à la pratique culturale la plus proche dans EcoALIM (LLM)
|
| 427 |
+
"""
|
| 428 |
+
result.actions_appliquees.append("1. Recherche dans ECOALIM pour la France")
|
| 429 |
+
eco_result = llm_service.smart_search_ecoalim(matiere, pays_production="France")
|
| 430 |
+
if eco_result:
|
| 431 |
+
val = eco_result["valeur_kg_co2_eq"]
|
| 432 |
+
result.impact_kg_co2_eq = val
|
| 433 |
+
result.impact_tonne_co2_eq = val
|
| 434 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 435 |
+
result.source_db = eco_result["source"]
|
| 436 |
+
result.intrant_utilise = eco_result["nom_intrant"]
|
| 437 |
+
result.match_exact = eco_result["match_exact"]
|
| 438 |
+
result.justification_alternative = eco_result.get("justification")
|
| 439 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']} = {val:.4f} kg CO2 eq/kg")
|
| 440 |
+
return result
|
| 441 |
+
|
| 442 |
+
result.actions_appliquees.append("2. Recherche dans GFLI pour FR")
|
| 443 |
+
gfli_result = llm_service.smart_search_gfli(matiere, country_iso="FR")
|
| 444 |
+
if gfli_result:
|
| 445 |
+
val = gfli_result["valeur_kg_co2_eq_par_tonne"]
|
| 446 |
+
result.impact_kg_co2_eq = val
|
| 447 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 448 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 449 |
+
result.source_db = gfli_result["source"]
|
| 450 |
+
result.intrant_utilise = gfli_result["nom_intrant"]
|
| 451 |
+
result.match_exact = gfli_result["match_exact"]
|
| 452 |
+
result.justification_alternative = gfli_result.get("justification")
|
| 453 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {gfli_result['nom_intrant']}")
|
| 454 |
+
return result
|
| 455 |
+
|
| 456 |
+
result.actions_appliquees.append("3. Recherche via LLM de la pratique culturale la plus proche dans ECOALIM")
|
| 457 |
+
eco_smart = llm_service.smart_search_ecoalim(matiere)
|
| 458 |
+
if eco_smart:
|
| 459 |
+
val = eco_smart["valeur_kg_co2_eq"]
|
| 460 |
+
result.impact_kg_co2_eq = val
|
| 461 |
+
result.impact_tonne_co2_eq = val
|
| 462 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 463 |
+
result.source_db = eco_smart["source"]
|
| 464 |
+
result.intrant_utilise = eco_smart["nom_intrant"]
|
| 465 |
+
result.match_exact = False
|
| 466 |
+
result.justification_alternative = eco_smart.get("justification")
|
| 467 |
+
result.actions_appliquees.append(f" → Via LLM : {eco_smart['nom_intrant']}")
|
| 468 |
+
return result
|
| 469 |
+
|
| 470 |
+
# Étape 4 : Fallback - Proposer des matières alternatives
|
| 471 |
+
result.actions_appliquees.append("4. Fallback - Recherche via LLM de 4 alternatives (France)")
|
| 472 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI", country_hint="France")
|
| 473 |
+
|
| 474 |
+
if alternatives:
|
| 475 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 476 |
+
if alternatives.get("itinerary"):
|
| 477 |
+
alt = alternatives["itinerary"]
|
| 478 |
+
result.alternatives_itinerary = {
|
| 479 |
+
"name": alt["name"],
|
| 480 |
+
"impact": alt["impact"],
|
| 481 |
+
"source": alt["source"],
|
| 482 |
+
"reasoning": alt["reasoning"],
|
| 483 |
+
}
|
| 484 |
+
if alternatives.get("locality"):
|
| 485 |
+
alt = alternatives["locality"]
|
| 486 |
+
result.alternatives_locality = {
|
| 487 |
+
"name": alt["name"],
|
| 488 |
+
"impact": alt["impact"],
|
| 489 |
+
"source": alt["source"],
|
| 490 |
+
"reasoning": alt["reasoning"],
|
| 491 |
+
}
|
| 492 |
+
if alternatives.get("form"):
|
| 493 |
+
alt = alternatives["form"]
|
| 494 |
+
result.alternatives_form = {
|
| 495 |
+
"name": alt["name"],
|
| 496 |
+
"impact": alt["impact"],
|
| 497 |
+
"source": alt["source"],
|
| 498 |
+
"reasoning": alt["reasoning"],
|
| 499 |
+
}
|
| 500 |
+
if alternatives.get("combined"):
|
| 501 |
+
alt = alternatives["combined"]
|
| 502 |
+
result.alternatives_combined = {
|
| 503 |
+
"name": alt["name"],
|
| 504 |
+
"impact": alt["impact"],
|
| 505 |
+
"source": alt["source"],
|
| 506 |
+
"reasoning": alt["reasoning"],
|
| 507 |
+
}
|
| 508 |
+
# Utiliser la combined comme valeur principale
|
| 509 |
+
val = alt["impact"]
|
| 510 |
+
result.impact_kg_co2_eq = val
|
| 511 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 512 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 513 |
+
result.source_db = alt["source"]
|
| 514 |
+
result.intrant_utilise = alt["name"]
|
| 515 |
+
result.match_exact = False
|
| 516 |
+
result.justification_alternative = alt["reasoning"]
|
| 517 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 518 |
+
return result
|
| 519 |
+
|
| 520 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (brut, France)."
|
| 521 |
+
return result
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _resolve_node_9(matiere: str, pays_production: str, result: CarbonResult) -> CarbonResult:
|
| 525 |
+
"""
|
| 526 |
+
Node 9 : Provenance connue + brut + cultivé hors France.
|
| 527 |
+
1. GFLI du pays correspondant
|
| 528 |
+
2. RER (Europe) ou GLO (autre continent)
|
| 529 |
+
3. EcoALIM
|
| 530 |
+
"""
|
| 531 |
+
country_iso = _get_country_iso(pays_production)
|
| 532 |
+
|
| 533 |
+
result.actions_appliquees.append(f"1. Recherche dans GFLI pour le pays {pays_production} (ISO: {country_iso})")
|
| 534 |
+
gfli_result = llm_service.smart_search_gfli(matiere, country_iso=country_iso)
|
| 535 |
+
if gfli_result:
|
| 536 |
+
val = gfli_result["valeur_kg_co2_eq_par_tonne"]
|
| 537 |
+
result.impact_kg_co2_eq = val
|
| 538 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 539 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 540 |
+
result.source_db = gfli_result["source"]
|
| 541 |
+
result.intrant_utilise = gfli_result["nom_intrant"]
|
| 542 |
+
result.match_exact = gfli_result["match_exact"]
|
| 543 |
+
result.justification_alternative = gfli_result.get("justification")
|
| 544 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {gfli_result['nom_intrant']}")
|
| 545 |
+
return result
|
| 546 |
+
|
| 547 |
+
# Étape 2 : RER ou GLO
|
| 548 |
+
is_eu = _is_european(pays_production)
|
| 549 |
+
if is_eu:
|
| 550 |
+
result.actions_appliquees.append("2. Pays européen → Recherche Mix Européen (RER) dans GFLI")
|
| 551 |
+
rer = llm_service.smart_search_gfli(matiere, country_iso="RER")
|
| 552 |
+
if rer:
|
| 553 |
+
val = rer["valeur_kg_co2_eq_par_tonne"]
|
| 554 |
+
result.impact_kg_co2_eq = val
|
| 555 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 556 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 557 |
+
result.source_db = rer["source"] + " (Mix Européen RER)"
|
| 558 |
+
result.intrant_utilise = rer["nom_intrant"]
|
| 559 |
+
result.match_exact = rer["match_exact"]
|
| 560 |
+
result.justification_alternative = rer.get("justification")
|
| 561 |
+
result.actions_appliquees.append(f" → Trouvé RER : {rer['nom_intrant']}")
|
| 562 |
+
return result
|
| 563 |
+
else:
|
| 564 |
+
result.actions_appliquees.append("2. Pays hors Europe → Recherche Mix Monde (GLO) dans GFLI")
|
| 565 |
+
glo = llm_service.smart_search_gfli(matiere, country_iso="GLO")
|
| 566 |
+
if glo:
|
| 567 |
+
val = glo["valeur_kg_co2_eq_par_tonne"]
|
| 568 |
+
result.impact_kg_co2_eq = val
|
| 569 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 570 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 571 |
+
result.source_db = glo["source"] + " (Mix Monde GLO)"
|
| 572 |
+
result.intrant_utilise = glo["nom_intrant"]
|
| 573 |
+
result.match_exact = glo["match_exact"]
|
| 574 |
+
result.justification_alternative = glo.get("justification")
|
| 575 |
+
result.actions_appliquees.append(f" → Trouvé GLO : {glo['nom_intrant']}")
|
| 576 |
+
return result
|
| 577 |
+
|
| 578 |
+
result.actions_appliquees.append("3. Recherche dans ECOALIM")
|
| 579 |
+
eco_result = llm_service.smart_search_ecoalim(matiere, pays_production=pays_production)
|
| 580 |
+
if eco_result:
|
| 581 |
+
val = eco_result["valeur_kg_co2_eq"]
|
| 582 |
+
result.impact_kg_co2_eq = val
|
| 583 |
+
result.impact_tonne_co2_eq = val
|
| 584 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 585 |
+
result.source_db = eco_result["source"]
|
| 586 |
+
result.intrant_utilise = eco_result["nom_intrant"]
|
| 587 |
+
result.match_exact = eco_result["match_exact"]
|
| 588 |
+
result.justification_alternative = eco_result.get("justification")
|
| 589 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']}")
|
| 590 |
+
return result
|
| 591 |
+
|
| 592 |
+
# Étape 4 : Fallback - Proposer des matières alternatives
|
| 593 |
+
result.actions_appliquees.append(f"4. Fallback - Recherche via LLM de 4 alternatives ({pays_production})")
|
| 594 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI", country_hint=pays_production)
|
| 595 |
+
|
| 596 |
+
if alternatives:
|
| 597 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 598 |
+
if alternatives.get("itinerary"):
|
| 599 |
+
alt = alternatives["itinerary"]
|
| 600 |
+
result.alternatives_itinerary = {
|
| 601 |
+
"name": alt["name"],
|
| 602 |
+
"impact": alt["impact"],
|
| 603 |
+
"source": alt["source"],
|
| 604 |
+
"reasoning": alt["reasoning"],
|
| 605 |
+
}
|
| 606 |
+
if alternatives.get("locality"):
|
| 607 |
+
alt = alternatives["locality"]
|
| 608 |
+
result.alternatives_locality = {
|
| 609 |
+
"name": alt["name"],
|
| 610 |
+
"impact": alt["impact"],
|
| 611 |
+
"source": alt["source"],
|
| 612 |
+
"reasoning": alt["reasoning"],
|
| 613 |
+
}
|
| 614 |
+
if alternatives.get("form"):
|
| 615 |
+
alt = alternatives["form"]
|
| 616 |
+
result.alternatives_form = {
|
| 617 |
+
"name": alt["name"],
|
| 618 |
+
"impact": alt["impact"],
|
| 619 |
+
"source": alt["source"],
|
| 620 |
+
"reasoning": alt["reasoning"],
|
| 621 |
+
}
|
| 622 |
+
if alternatives.get("combined"):
|
| 623 |
+
alt = alternatives["combined"]
|
| 624 |
+
result.alternatives_combined = {
|
| 625 |
+
"name": alt["name"],
|
| 626 |
+
"impact": alt["impact"],
|
| 627 |
+
"source": alt["source"],
|
| 628 |
+
"reasoning": alt["reasoning"],
|
| 629 |
+
}
|
| 630 |
+
# Utiliser la combined comme valeur principale
|
| 631 |
+
val = alt["impact"]
|
| 632 |
+
result.impact_kg_co2_eq = val
|
| 633 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 634 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 635 |
+
result.source_db = alt["source"]
|
| 636 |
+
result.intrant_utilise = alt["name"]
|
| 637 |
+
result.match_exact = False
|
| 638 |
+
result.justification_alternative = alt["reasoning"]
|
| 639 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 640 |
+
return result
|
| 641 |
+
|
| 642 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (brut, {pays_production})."
|
| 643 |
+
return result
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _resolve_node_10(matiere: str, result: CarbonResult) -> CarbonResult:
|
| 647 |
+
"""
|
| 648 |
+
Node 10 : Provenance connue + transformé + France/France.
|
| 649 |
+
1. EcoALIM pour l'intrant transformé
|
| 650 |
+
2. A/ impact process connu : brut EcoALIM + process / B/ sinon GFLI
|
| 651 |
+
3. Intrant au process le plus proche dans EcoALIM (LLM)
|
| 652 |
+
"""
|
| 653 |
+
result.actions_appliquees.append("1. Recherche dans ECOALIM (transformé France/France)")
|
| 654 |
+
eco_result = llm_service.smart_search_ecoalim(matiere, pays_production="France", pays_transformation="France")
|
| 655 |
+
if eco_result:
|
| 656 |
+
val = eco_result["valeur_kg_co2_eq"]
|
| 657 |
+
result.impact_kg_co2_eq = val
|
| 658 |
+
result.impact_tonne_co2_eq = val
|
| 659 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 660 |
+
result.source_db = eco_result["source"]
|
| 661 |
+
result.intrant_utilise = eco_result["nom_intrant"]
|
| 662 |
+
result.match_exact = eco_result["match_exact"]
|
| 663 |
+
result.justification_alternative = eco_result.get("justification")
|
| 664 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']} = {val:.4f}")
|
| 665 |
+
return result
|
| 666 |
+
|
| 667 |
+
# Étape 2 : GFLI France
|
| 668 |
+
result.actions_appliquees.append("2. Impact process non connu → Recherche dans GFLI (FR)")
|
| 669 |
+
gfli_result = llm_service.smart_search_gfli(matiere, country_iso="FR")
|
| 670 |
+
if gfli_result:
|
| 671 |
+
val = gfli_result["valeur_kg_co2_eq_par_tonne"]
|
| 672 |
+
result.impact_kg_co2_eq = val
|
| 673 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 674 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 675 |
+
result.source_db = gfli_result["source"]
|
| 676 |
+
result.intrant_utilise = gfli_result["nom_intrant"]
|
| 677 |
+
result.match_exact = gfli_result["match_exact"]
|
| 678 |
+
result.justification_alternative = gfli_result.get("justification")
|
| 679 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {gfli_result['nom_intrant']}")
|
| 680 |
+
return result
|
| 681 |
+
|
| 682 |
+
# Étape 3 : LLM process le plus proche
|
| 683 |
+
result.actions_appliquees.append("3. Recherche via LLM du process le plus proche dans ECOALIM")
|
| 684 |
+
eco_smart = llm_service.smart_search_ecoalim(matiere)
|
| 685 |
+
if eco_smart:
|
| 686 |
+
val = eco_smart["valeur_kg_co2_eq"]
|
| 687 |
+
result.impact_kg_co2_eq = val
|
| 688 |
+
result.impact_tonne_co2_eq = val
|
| 689 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 690 |
+
result.source_db = eco_smart["source"]
|
| 691 |
+
result.intrant_utilise = eco_smart["nom_intrant"]
|
| 692 |
+
result.match_exact = False
|
| 693 |
+
result.justification_alternative = eco_smart.get("justification")
|
| 694 |
+
result.actions_appliquees.append(f" → Via LLM : {eco_smart['nom_intrant']}")
|
| 695 |
+
return result
|
| 696 |
+
|
| 697 |
+
# Étape 4 : Fallback - Proposer des matières alternatives
|
| 698 |
+
result.actions_appliquees.append("4. Fallback - Recherche via LLM de 4 alternatives (France)")
|
| 699 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI", country_hint="France")
|
| 700 |
+
|
| 701 |
+
if alternatives:
|
| 702 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 703 |
+
if alternatives.get("itinerary"):
|
| 704 |
+
alt = alternatives["itinerary"]
|
| 705 |
+
result.alternatives_itinerary = {
|
| 706 |
+
"name": alt["name"],
|
| 707 |
+
"impact": alt["impact"],
|
| 708 |
+
"source": alt["source"],
|
| 709 |
+
"reasoning": alt["reasoning"],
|
| 710 |
+
}
|
| 711 |
+
if alternatives.get("locality"):
|
| 712 |
+
alt = alternatives["locality"]
|
| 713 |
+
result.alternatives_locality = {
|
| 714 |
+
"name": alt["name"],
|
| 715 |
+
"impact": alt["impact"],
|
| 716 |
+
"source": alt["source"],
|
| 717 |
+
"reasoning": alt["reasoning"],
|
| 718 |
+
}
|
| 719 |
+
if alternatives.get("form"):
|
| 720 |
+
alt = alternatives["form"]
|
| 721 |
+
result.alternatives_form = {
|
| 722 |
+
"name": alt["name"],
|
| 723 |
+
"impact": alt["impact"],
|
| 724 |
+
"source": alt["source"],
|
| 725 |
+
"reasoning": alt["reasoning"],
|
| 726 |
+
}
|
| 727 |
+
if alternatives.get("combined"):
|
| 728 |
+
alt = alternatives["combined"]
|
| 729 |
+
result.alternatives_combined = {
|
| 730 |
+
"name": alt["name"],
|
| 731 |
+
"impact": alt["impact"],
|
| 732 |
+
"source": alt["source"],
|
| 733 |
+
"reasoning": alt["reasoning"],
|
| 734 |
+
}
|
| 735 |
+
# Utiliser la combined comme valeur principale
|
| 736 |
+
val = alt["impact"]
|
| 737 |
+
result.impact_kg_co2_eq = val
|
| 738 |
+
result.impact_tonne_co2_eq = val
|
| 739 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 740 |
+
result.source_db = alt["source"]
|
| 741 |
+
result.intrant_utilise = alt["name"]
|
| 742 |
+
result.match_exact = False
|
| 743 |
+
result.justification_alternative = alt["reasoning"]
|
| 744 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.4f} kg CO2 eq/kg")
|
| 745 |
+
return result
|
| 746 |
+
|
| 747 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé, France/France)."
|
| 748 |
+
return result
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def _resolve_node_11(matiere: str, result: CarbonResult) -> CarbonResult:
|
| 752 |
+
"""
|
| 753 |
+
Node 11 : Transformé en France, MP brute non FR ou inconnue.
|
| 754 |
+
1. GFLI France
|
| 755 |
+
2. A/ process connu → brut GFLI + process / B/ sinon RER
|
| 756 |
+
3. EcoALIM
|
| 757 |
+
4. Pratique culturale la plus proche GFLI (LLM)
|
| 758 |
+
"""
|
| 759 |
+
result.actions_appliquees.append("1. Recherche dans GFLI (France)")
|
| 760 |
+
gfli_result = llm_service.smart_search_gfli(matiere, country_iso="FR")
|
| 761 |
+
if gfli_result:
|
| 762 |
+
val = gfli_result["valeur_kg_co2_eq_par_tonne"]
|
| 763 |
+
result.impact_kg_co2_eq = val
|
| 764 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 765 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 766 |
+
result.source_db = gfli_result["source"]
|
| 767 |
+
result.intrant_utilise = gfli_result["nom_intrant"]
|
| 768 |
+
result.match_exact = gfli_result["match_exact"]
|
| 769 |
+
result.justification_alternative = gfli_result.get("justification")
|
| 770 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {gfli_result['nom_intrant']}")
|
| 771 |
+
return result
|
| 772 |
+
|
| 773 |
+
# Étape 2 : RER
|
| 774 |
+
result.actions_appliquees.append("2. Impact process non connu → Recherche Mix Européen (RER) dans GFLI")
|
| 775 |
+
rer = llm_service.smart_search_gfli(matiere, country_iso="RER")
|
| 776 |
+
if rer:
|
| 777 |
+
val = rer["valeur_kg_co2_eq_par_tonne"]
|
| 778 |
+
result.impact_kg_co2_eq = val
|
| 779 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 780 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 781 |
+
result.source_db = rer["source"] + " (Mix Européen RER)"
|
| 782 |
+
result.intrant_utilise = rer["nom_intrant"]
|
| 783 |
+
result.match_exact = rer["match_exact"]
|
| 784 |
+
result.justification_alternative = rer.get("justification")
|
| 785 |
+
result.actions_appliquees.append(f" → Trouvé RER : {rer['nom_intrant']}")
|
| 786 |
+
return result
|
| 787 |
+
|
| 788 |
+
# Étape 3 : EcoALIM
|
| 789 |
+
result.actions_appliquees.append("3. Recherche dans ECOALIM")
|
| 790 |
+
eco_result = llm_service.smart_search_ecoalim(matiere)
|
| 791 |
+
if eco_result:
|
| 792 |
+
val = eco_result["valeur_kg_co2_eq"]
|
| 793 |
+
result.impact_kg_co2_eq = val
|
| 794 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 795 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 796 |
+
result.source_db = eco_result["source"]
|
| 797 |
+
result.intrant_utilise = eco_result["nom_intrant"]
|
| 798 |
+
result.match_exact = eco_result["match_exact"]
|
| 799 |
+
result.justification_alternative = eco_result.get("justification")
|
| 800 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']}")
|
| 801 |
+
return result
|
| 802 |
+
|
| 803 |
+
# Étape 4 : LLM
|
| 804 |
+
result.actions_appliquees.append("4. Recherche via LLM de la pratique culturale la plus proche (GFLI)")
|
| 805 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 806 |
+
if gfli_smart:
|
| 807 |
+
val = gfli_smart["valeur_kg_co2_eq_par_tonne"]
|
| 808 |
+
result.impact_kg_co2_eq = val
|
| 809 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 810 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 811 |
+
result.source_db = gfli_smart["source"]
|
| 812 |
+
result.intrant_utilise = gfli_smart["nom_intrant"]
|
| 813 |
+
result.match_exact = False
|
| 814 |
+
result.justification_alternative = gfli_smart.get("justification")
|
| 815 |
+
result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
|
| 816 |
+
return result
|
| 817 |
+
|
| 818 |
+
# Étape 5 : Fallback - Proposer des matières alternatives
|
| 819 |
+
result.actions_appliquees.append("5. Fallback - Recherche via LLM de 4 alternatives (France)")
|
| 820 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI", country_hint="France")
|
| 821 |
+
|
| 822 |
+
if alternatives:
|
| 823 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 824 |
+
if alternatives.get("itinerary"):
|
| 825 |
+
alt = alternatives["itinerary"]
|
| 826 |
+
result.alternatives_itinerary = {
|
| 827 |
+
"name": alt["name"],
|
| 828 |
+
"impact": alt["impact"],
|
| 829 |
+
"source": alt["source"],
|
| 830 |
+
"reasoning": alt["reasoning"],
|
| 831 |
+
}
|
| 832 |
+
if alternatives.get("locality"):
|
| 833 |
+
alt = alternatives["locality"]
|
| 834 |
+
result.alternatives_locality = {
|
| 835 |
+
"name": alt["name"],
|
| 836 |
+
"impact": alt["impact"],
|
| 837 |
+
"source": alt["source"],
|
| 838 |
+
"reasoning": alt["reasoning"],
|
| 839 |
+
}
|
| 840 |
+
if alternatives.get("form"):
|
| 841 |
+
alt = alternatives["form"]
|
| 842 |
+
result.alternatives_form = {
|
| 843 |
+
"name": alt["name"],
|
| 844 |
+
"impact": alt["impact"],
|
| 845 |
+
"source": alt["source"],
|
| 846 |
+
"reasoning": alt["reasoning"],
|
| 847 |
+
}
|
| 848 |
+
if alternatives.get("combined"):
|
| 849 |
+
alt = alternatives["combined"]
|
| 850 |
+
result.alternatives_combined = {
|
| 851 |
+
"name": alt["name"],
|
| 852 |
+
"impact": alt["impact"],
|
| 853 |
+
"source": alt["source"],
|
| 854 |
+
"reasoning": alt["reasoning"],
|
| 855 |
+
}
|
| 856 |
+
# Utiliser la combined comme valeur principale
|
| 857 |
+
val = alt["impact"]
|
| 858 |
+
result.impact_kg_co2_eq = val
|
| 859 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 860 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 861 |
+
result.source_db = alt["source"]
|
| 862 |
+
result.intrant_utilise = alt["name"]
|
| 863 |
+
result.match_exact = False
|
| 864 |
+
result.justification_alternative = alt["reasoning"]
|
| 865 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 866 |
+
return result
|
| 867 |
+
|
| 868 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé France, MP brute hors FR)."
|
| 869 |
+
return result
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
def _resolve_node_12(matiere: str, pays_transformation: str, result: CarbonResult) -> CarbonResult:
|
| 873 |
+
"""
|
| 874 |
+
Node 12 : Transformé hors France.
|
| 875 |
+
1. GFLI du pays correspondant
|
| 876 |
+
2. A/ process connu / B/ sinon RER (Europe) ou GLO (autre)
|
| 877 |
+
3. EcoALIM
|
| 878 |
+
4. Pratique culturale la plus proche GFLI (LLM)
|
| 879 |
+
"""
|
| 880 |
+
country_iso = _get_country_iso(pays_transformation)
|
| 881 |
+
|
| 882 |
+
result.actions_appliquees.append(f"1. Recherche dans GFLI pour {pays_transformation} (ISO: {country_iso})")
|
| 883 |
+
gfli_result = llm_service.smart_search_gfli(matiere, country_iso=country_iso)
|
| 884 |
+
if gfli_result:
|
| 885 |
+
val = gfli_result["valeur_kg_co2_eq_par_tonne"]
|
| 886 |
+
result.impact_kg_co2_eq = val
|
| 887 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 888 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 889 |
+
result.source_db = gfli_result["source"]
|
| 890 |
+
result.intrant_utilise = gfli_result["nom_intrant"]
|
| 891 |
+
result.match_exact = gfli_result["match_exact"]
|
| 892 |
+
result.justification_alternative = gfli_result.get("justification")
|
| 893 |
+
result.actions_appliquees.append(f" → Trouvé dans GFLI : {gfli_result['nom_intrant']}")
|
| 894 |
+
return result
|
| 895 |
+
|
| 896 |
+
# Étape 2 : RER ou GLO
|
| 897 |
+
is_eu = _is_european(pays_transformation)
|
| 898 |
+
if is_eu:
|
| 899 |
+
result.actions_appliquees.append("2. Pays européen → Recherche Mix Européen (RER)")
|
| 900 |
+
fallback = llm_service.smart_search_gfli(matiere, country_iso="RER")
|
| 901 |
+
else:
|
| 902 |
+
result.actions_appliquees.append("2. Pays hors Europe → Recherche Mix Monde (GLO)")
|
| 903 |
+
fallback = llm_service.smart_search_gfli(matiere, country_iso="GLO")
|
| 904 |
+
|
| 905 |
+
if fallback:
|
| 906 |
+
val = fallback["valeur_kg_co2_eq_par_tonne"]
|
| 907 |
+
result.impact_kg_co2_eq = val
|
| 908 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 909 |
+
mix_type = "RER" if is_eu else "GLO"
|
| 910 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 911 |
+
result.source_db = fallback["source"] + f" (Mix {mix_type})"
|
| 912 |
+
result.intrant_utilise = fallback["nom_intrant"]
|
| 913 |
+
result.match_exact = fallback["match_exact"]
|
| 914 |
+
result.justification_alternative = fallback.get("justification")
|
| 915 |
+
result.actions_appliquees.append(f" → Trouvé {mix_type} : {fallback['nom_intrant']}")
|
| 916 |
+
return result
|
| 917 |
+
|
| 918 |
+
# Étape 3 : EcoALIM
|
| 919 |
+
result.actions_appliquees.append("3. Recherche dans ECOALIM")
|
| 920 |
+
eco_result = llm_service.smart_search_ecoalim(matiere)
|
| 921 |
+
if eco_result:
|
| 922 |
+
val = eco_result["valeur_kg_co2_eq"]
|
| 923 |
+
result.impact_kg_co2_eq = val
|
| 924 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 925 |
+
result.unite_source = "kg CO2 eq / kg de produit"
|
| 926 |
+
result.source_db = eco_result["source"]
|
| 927 |
+
result.intrant_utilise = eco_result["nom_intrant"]
|
| 928 |
+
result.match_exact = eco_result["match_exact"]
|
| 929 |
+
result.justification_alternative = eco_result.get("justification")
|
| 930 |
+
result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']}")
|
| 931 |
+
return result
|
| 932 |
+
|
| 933 |
+
# Étape 4 : LLM
|
| 934 |
+
result.actions_appliquees.append("4. Recherche via LLM de la pratique culturale la plus proche (GFLI)")
|
| 935 |
+
gfli_smart = llm_service.smart_search_gfli(matiere)
|
| 936 |
+
if gfli_smart:
|
| 937 |
+
val = gfli_smart["valeur_kg_co2_eq_par_tonne"]
|
| 938 |
+
result.impact_kg_co2_eq = val
|
| 939 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 940 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 941 |
+
result.source_db = gfli_smart["source"]
|
| 942 |
+
result.intrant_utilise = gfli_smart["nom_intrant"]
|
| 943 |
+
result.match_exact = False
|
| 944 |
+
result.justification_alternative = gfli_smart.get("justification")
|
| 945 |
+
result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
|
| 946 |
+
return result
|
| 947 |
+
|
| 948 |
+
# Étape 5 : Fallback - Proposer des matières alternatives
|
| 949 |
+
result.actions_appliquees.append(f"5. Fallback - Recherche via LLM de 4 alternatives ({pays_transformation})")
|
| 950 |
+
alternatives = llm_service.find_alternative_materials(matiere, db_name="GFLI", country_hint=pays_transformation)
|
| 951 |
+
|
| 952 |
+
if alternatives:
|
| 953 |
+
# Stocker les 4 alternatives dans CarbonResult
|
| 954 |
+
if alternatives.get("itinerary"):
|
| 955 |
+
alt = alternatives["itinerary"]
|
| 956 |
+
result.alternatives_itinerary = {
|
| 957 |
+
"name": alt["name"],
|
| 958 |
+
"impact": alt["impact"],
|
| 959 |
+
"source": alt["source"],
|
| 960 |
+
"reasoning": alt["reasoning"],
|
| 961 |
+
}
|
| 962 |
+
if alternatives.get("locality"):
|
| 963 |
+
alt = alternatives["locality"]
|
| 964 |
+
result.alternatives_locality = {
|
| 965 |
+
"name": alt["name"],
|
| 966 |
+
"impact": alt["impact"],
|
| 967 |
+
"source": alt["source"],
|
| 968 |
+
"reasoning": alt["reasoning"],
|
| 969 |
+
}
|
| 970 |
+
if alternatives.get("form"):
|
| 971 |
+
alt = alternatives["form"]
|
| 972 |
+
result.alternatives_form = {
|
| 973 |
+
"name": alt["name"],
|
| 974 |
+
"impact": alt["impact"],
|
| 975 |
+
"source": alt["source"],
|
| 976 |
+
"reasoning": alt["reasoning"],
|
| 977 |
+
}
|
| 978 |
+
if alternatives.get("combined"):
|
| 979 |
+
alt = alternatives["combined"]
|
| 980 |
+
result.alternatives_combined = {
|
| 981 |
+
"name": alt["name"],
|
| 982 |
+
"impact": alt["impact"],
|
| 983 |
+
"source": alt["source"],
|
| 984 |
+
"reasoning": alt["reasoning"],
|
| 985 |
+
}
|
| 986 |
+
# Utiliser la combined comme valeur principale
|
| 987 |
+
val = alt["impact"]
|
| 988 |
+
result.impact_kg_co2_eq = val
|
| 989 |
+
result.impact_tonne_co2_eq = val / 1000.0
|
| 990 |
+
result.unite_source = "kg CO2 eq / tonne de produit"
|
| 991 |
+
result.source_db = alt["source"]
|
| 992 |
+
result.intrant_utilise = alt["name"]
|
| 993 |
+
result.match_exact = False
|
| 994 |
+
result.justification_alternative = alt["reasoning"]
|
| 995 |
+
result.actions_appliquees.append(f" → Matière proposée (combo) : {alt['name']} = {val:.2f} kg CO2 eq/t")
|
| 996 |
+
return result
|
| 997 |
+
|
| 998 |
+
result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé hors France)."
|
| 999 |
+
return result
|
| 1000 |
+
|
| 1001 |
+
|
| 1002 |
+
# ============================================================================
|
| 1003 |
+
# Moteur principal
|
| 1004 |
+
# ============================================================================
|
| 1005 |
+
|
| 1006 |
+
def evaluate_carbon_impact(
|
| 1007 |
+
matiere_premiere: str,
|
| 1008 |
+
pays_production: Optional[str] = None,
|
| 1009 |
+
pays_transformation: Optional[str] = None,
|
| 1010 |
+
) -> CarbonResult:
|
| 1011 |
+
"""
|
| 1012 |
+
Point d'entrée principal : évalue l'impact carbone d'une matière première
|
| 1013 |
+
en suivant le logigramme.
|
| 1014 |
+
|
| 1015 |
+
Args:
|
| 1016 |
+
matiere_premiere: Nom de la matière première (ex: "BLE", "T.TNSL DEC.", "SOJA")
|
| 1017 |
+
pays_production: Pays de production de la MP brute (ex: "France", "Brésil") ou None si inconnu
|
| 1018 |
+
pays_transformation: Pays de transformation (ex: "France") ou None si pas de transformation
|
| 1019 |
+
|
| 1020 |
+
Returns:
|
| 1021 |
+
CarbonResult avec toutes les informations
|
| 1022 |
+
"""
|
| 1023 |
+
result = CarbonResult(
|
| 1024 |
+
matiere_premiere=matiere_premiere,
|
| 1025 |
+
pays_production=pays_production,
|
| 1026 |
+
pays_transformation=pays_transformation,
|
| 1027 |
+
classification="",
|
| 1028 |
+
classification_justification="",
|
| 1029 |
+
)
|
| 1030 |
+
|
| 1031 |
+
# -----------------------------------------------------------------------
|
| 1032 |
+
# Étape 1 : Classifier brut vs transformé via LLM + PDF CIR
|
| 1033 |
+
# -----------------------------------------------------------------------
|
| 1034 |
+
classification = llm_service.determine_brut_ou_transforme(matiere_premiere)
|
| 1035 |
+
result.classification = classification.get("classification", "brut")
|
| 1036 |
+
result.classification_justification = classification.get("justification", "")
|
| 1037 |
+
is_transformed = result.classification == "transforme"
|
| 1038 |
+
|
| 1039 |
+
result.parcours.append(StepLog(
|
| 1040 |
+
node_id="classification",
|
| 1041 |
+
question="La matière est-elle brute ou transformée ?",
|
| 1042 |
+
answer=f"{'Transformée' if is_transformed else 'Brute'} — {result.classification_justification}",
|
| 1043 |
+
))
|
| 1044 |
+
|
| 1045 |
+
# -----------------------------------------------------------------------
|
| 1046 |
+
# Étape 2 : Node 1 - Connaît-on la provenance ?
|
| 1047 |
+
# -----------------------------------------------------------------------
|
| 1048 |
+
provenance_connue = bool(pays_production)
|
| 1049 |
+
|
| 1050 |
+
if not provenance_connue:
|
| 1051 |
+
result.parcours.append(StepLog(
|
| 1052 |
+
node_id="node_1",
|
| 1053 |
+
question="Connaissez-vous l'endroit où l'intrant a été cultivé ou produit ?",
|
| 1054 |
+
answer="Non — provenance inconnue",
|
| 1055 |
+
))
|
| 1056 |
+
|
| 1057 |
+
# Node 2 : brut ou transformé ?
|
| 1058 |
+
if not is_transformed:
|
| 1059 |
+
result.parcours.append(StepLog(
|
| 1060 |
+
node_id="node_2",
|
| 1061 |
+
question="Quel est le niveau de transformation ?",
|
| 1062 |
+
answer="Intrant brut/non transformé",
|
| 1063 |
+
))
|
| 1064 |
+
result.node_resultat = "node_4"
|
| 1065 |
+
result = _resolve_node_4(matiere_premiere, result)
|
| 1066 |
+
else:
|
| 1067 |
+
result.parcours.append(StepLog(
|
| 1068 |
+
node_id="node_2",
|
| 1069 |
+
question="Quel est le niveau de transformation ?",
|
| 1070 |
+
answer="Coproduit/intrant transformé",
|
| 1071 |
+
))
|
| 1072 |
+
result.node_resultat = "node_5"
|
| 1073 |
+
result = _resolve_node_5(matiere_premiere, result)
|
| 1074 |
+
|
| 1075 |
+
else:
|
| 1076 |
+
# Provenance connue
|
| 1077 |
+
result.parcours.append(StepLog(
|
| 1078 |
+
node_id="node_1",
|
| 1079 |
+
question="Connaissez-vous l'endroit où l'intrant a été cultivé ou produit ?",
|
| 1080 |
+
answer=f"Oui — Production: {pays_production}" + (f", Transformation: {pays_transformation}" if pays_transformation else ""),
|
| 1081 |
+
))
|
| 1082 |
+
|
| 1083 |
+
if not is_transformed:
|
| 1084 |
+
# Node 3 → Node 6 : où a-t-il été cultivé ?
|
| 1085 |
+
result.parcours.append(StepLog(
|
| 1086 |
+
node_id="node_3",
|
| 1087 |
+
question="Quel est le niveau de transformation ?",
|
| 1088 |
+
answer="Intrant brut/non transformé",
|
| 1089 |
+
))
|
| 1090 |
+
|
| 1091 |
+
if _is_france(pays_production):
|
| 1092 |
+
result.parcours.append(StepLog(
|
| 1093 |
+
node_id="node_6",
|
| 1094 |
+
question="Où l'intrant brut a-t-il été cultivé ?",
|
| 1095 |
+
answer="En France",
|
| 1096 |
+
))
|
| 1097 |
+
result.node_resultat = "node_8"
|
| 1098 |
+
result = _resolve_node_8(matiere_premiere, result)
|
| 1099 |
+
else:
|
| 1100 |
+
result.parcours.append(StepLog(
|
| 1101 |
+
node_id="node_6",
|
| 1102 |
+
question="Où l'intrant brut a-t-il été cultivé ?",
|
| 1103 |
+
answer=f"Hors France — {pays_production}",
|
| 1104 |
+
))
|
| 1105 |
+
result.node_resultat = "node_9"
|
| 1106 |
+
result = _resolve_node_9(matiere_premiere, pays_production, result)
|
| 1107 |
+
|
| 1108 |
+
else:
|
| 1109 |
+
# Node 3 → Node 7 : où transformé + origine MP brute ?
|
| 1110 |
+
result.parcours.append(StepLog(
|
| 1111 |
+
node_id="node_3",
|
| 1112 |
+
question="Quel est le niveau de transformation ?",
|
| 1113 |
+
answer="Coproduit/intrant transformé",
|
| 1114 |
+
))
|
| 1115 |
+
|
| 1116 |
+
if _is_france(pays_transformation) and _is_france(pays_production):
|
| 1117 |
+
result.parcours.append(StepLog(
|
| 1118 |
+
node_id="node_7",
|
| 1119 |
+
question="Où l'intrant a-t-il été transformé et d'où provient la MP brute ?",
|
| 1120 |
+
answer="Transformé en France à partir de MP brute française",
|
| 1121 |
+
))
|
| 1122 |
+
result.node_resultat = "node_10"
|
| 1123 |
+
result = _resolve_node_10(matiere_premiere, result)
|
| 1124 |
+
|
| 1125 |
+
elif _is_france(pays_transformation):
|
| 1126 |
+
result.parcours.append(StepLog(
|
| 1127 |
+
node_id="node_7",
|
| 1128 |
+
question="Où l'intrant a-t-il été transformé et d'où provient la MP brute ?",
|
| 1129 |
+
answer=f"Transformé en France, MP brute de {pays_production or 'origine inconnue'}",
|
| 1130 |
+
))
|
| 1131 |
+
result.node_resultat = "node_11"
|
| 1132 |
+
result = _resolve_node_11(matiere_premiere, result)
|
| 1133 |
+
|
| 1134 |
+
else:
|
| 1135 |
+
result.parcours.append(StepLog(
|
| 1136 |
+
node_id="node_7",
|
| 1137 |
+
question="Où l'intrant a-t-il été transformé et d'où provient la MP brute ?",
|
| 1138 |
+
answer=f"Transformé hors France — {pays_transformation}",
|
| 1139 |
+
))
|
| 1140 |
+
result.node_resultat = "node_12"
|
| 1141 |
+
result = _resolve_node_12(matiere_premiere, pays_transformation or pays_production or "", result)
|
| 1142 |
+
|
| 1143 |
+
# ------------------------------------------------------------------
|
| 1144 |
+
# Post-processing : normaliser les unités (t CO2 eq / t produit)
|
| 1145 |
+
# ------------------------------------------------------------------
|
| 1146 |
+
if result.impact_kg_co2_eq is not None and result.unite_source:
|
| 1147 |
+
if "tonne" in result.unite_source:
|
| 1148 |
+
# GFLI : kg CO2 eq / tonne -> t CO2 eq / t
|
| 1149 |
+
result.impact_tonne_co2_eq = result.impact_kg_co2_eq / 1000.0
|
| 1150 |
+
else:
|
| 1151 |
+
# EcoALIM : kg CO2 eq / kg -> t CO2 eq / t (même valeur numérique)
|
| 1152 |
+
result.impact_tonne_co2_eq = result.impact_kg_co2_eq
|
| 1153 |
+
|
| 1154 |
+
# ------------------------------------------------------------------
|
| 1155 |
+
# Post-processing : collecter les candidats alternatifs
|
| 1156 |
+
# ------------------------------------------------------------------
|
| 1157 |
+
result = _collect_candidates(result)
|
| 1158 |
+
|
| 1159 |
+
# Demander au LLM quel candidat est le plus pertinent en cas de doute
|
| 1160 |
+
if not result.match_exact and result.candidats_alternatifs:
|
| 1161 |
+
try:
|
| 1162 |
+
names = [c.get("nom", "") for c in result.candidats_alternatifs if c.get("nom")]
|
| 1163 |
+
rank = llm_service.rank_candidates(result.matiere_premiere, names)
|
| 1164 |
+
result.candidat_recommande = rank.get("best_name")
|
| 1165 |
+
result.candidats_reflexion = rank.get("reasoning")
|
| 1166 |
+
except Exception:
|
| 1167 |
+
result.candidat_recommande = None
|
| 1168 |
+
result.candidats_reflexion = None
|
| 1169 |
+
|
| 1170 |
+
# Générer une justification LLM si le match n'est pas exact et qu'il n'y en a pas
|
| 1171 |
+
if not result.match_exact and not result.justification_alternative and not result.erreur:
|
| 1172 |
+
if result.intrant_utilise and result.impact_kg_co2_eq is not None:
|
| 1173 |
+
try:
|
| 1174 |
+
result.justification_alternative = llm_service.justify_alternative_value(
|
| 1175 |
+
result.matiere_premiere,
|
| 1176 |
+
result.intrant_utilise,
|
| 1177 |
+
result.impact_kg_co2_eq,
|
| 1178 |
+
result.source_db,
|
| 1179 |
+
)
|
| 1180 |
+
except Exception:
|
| 1181 |
+
result.justification_alternative = (
|
| 1182 |
+
f"Valeur de '{result.intrant_utilise}' utilisée comme proxy pour "
|
| 1183 |
+
f"'{result.matiere_premiere}' (matière la plus proche dans {result.source_db})."
|
| 1184 |
+
)
|
| 1185 |
+
|
| 1186 |
+
return result
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
def _collect_candidates(result: CarbonResult) -> CarbonResult:
|
| 1190 |
+
"""
|
| 1191 |
+
Après résolution, cherche les autres produits correspondants dans la même
|
| 1192 |
+
base de données pour proposer des alternatives triées par pertinence.
|
| 1193 |
+
"""
|
| 1194 |
+
if result.erreur or result.intrant_utilise is None:
|
| 1195 |
+
return result
|
| 1196 |
+
|
| 1197 |
+
matiere = result.matiere_premiere
|
| 1198 |
+
source = result.source_db or ""
|
| 1199 |
+
|
| 1200 |
+
candidates: list[dict] = []
|
| 1201 |
+
|
| 1202 |
+
# Déterminer le pays ISO pour GFLI
|
| 1203 |
+
country_iso = None
|
| 1204 |
+
if result.pays_production:
|
| 1205 |
+
country_iso = _get_country_iso(result.pays_production)
|
| 1206 |
+
if result.pays_transformation:
|
| 1207 |
+
country_iso = _get_country_iso(result.pays_transformation) or country_iso
|
| 1208 |
+
|
| 1209 |
+
# Collecter depuis la source utilisée + l'autre source
|
| 1210 |
+
# D'abord la source principalement utilisée
|
| 1211 |
+
unbounded = not result.match_exact
|
| 1212 |
+
matiere_fr = llm_service.translate_matiere_to_french(matiere)
|
| 1213 |
+
matiere_en = llm_service.translate_matiere_to_english(matiere)
|
| 1214 |
+
if "ECOALIM" in source.upper():
|
| 1215 |
+
candidates.extend(data_loader.get_top_ecoalim_candidates(
|
| 1216 |
+
matiere,
|
| 1217 |
+
pays_production=result.pays_production,
|
| 1218 |
+
pays_transformation=result.pays_transformation,
|
| 1219 |
+
top_n=None if unbounded else 8,
|
| 1220 |
+
))
|
| 1221 |
+
if matiere_fr.lower() != matiere.lower():
|
| 1222 |
+
candidates.extend(data_loader.get_top_ecoalim_candidates(
|
| 1223 |
+
matiere_fr,
|
| 1224 |
+
pays_production=result.pays_production,
|
| 1225 |
+
pays_transformation=result.pays_transformation,
|
| 1226 |
+
top_n=None if unbounded else 8,
|
| 1227 |
+
))
|
| 1228 |
+
candidates.extend(data_loader.get_top_gfli_candidates(
|
| 1229 |
+
matiere, country_iso=country_iso, top_n=None if unbounded else 4,
|
| 1230 |
+
))
|
| 1231 |
+
if matiere_en.lower() != matiere.lower():
|
| 1232 |
+
candidates.extend(data_loader.get_top_gfli_candidates(
|
| 1233 |
+
matiere_en, country_iso=country_iso, top_n=None if unbounded else 4,
|
| 1234 |
+
))
|
| 1235 |
+
else:
|
| 1236 |
+
# Essayer aussi avec le nom traduit si on est sur GFLI
|
| 1237 |
+
# Le nom d'intrant utilisé contient le terme anglais
|
| 1238 |
+
intrant_base = result.intrant_utilise.split(",")[0].split("/")[0].strip()
|
| 1239 |
+
candidates.extend(data_loader.get_top_gfli_candidates(
|
| 1240 |
+
intrant_base, country_iso=country_iso, top_n=None if unbounded else 8,
|
| 1241 |
+
))
|
| 1242 |
+
if matiere_en.lower() != matiere.lower():
|
| 1243 |
+
candidates.extend(data_loader.get_top_gfli_candidates(
|
| 1244 |
+
matiere_en, country_iso=country_iso, top_n=None if unbounded else 8,
|
| 1245 |
+
))
|
| 1246 |
+
candidates.extend(data_loader.get_top_ecoalim_candidates(
|
| 1247 |
+
matiere,
|
| 1248 |
+
pays_production=result.pays_production,
|
| 1249 |
+
pays_transformation=result.pays_transformation,
|
| 1250 |
+
top_n=None if unbounded else 4,
|
| 1251 |
+
))
|
| 1252 |
+
if matiere_fr.lower() != matiere.lower():
|
| 1253 |
+
candidates.extend(data_loader.get_top_ecoalim_candidates(
|
| 1254 |
+
matiere_fr,
|
| 1255 |
+
pays_production=result.pays_production,
|
| 1256 |
+
pays_transformation=result.pays_transformation,
|
| 1257 |
+
top_n=None if unbounded else 4,
|
| 1258 |
+
))
|
| 1259 |
+
|
| 1260 |
+
# Dédupliquer, exclure l'intrant sélectionné, et filtrer les faux positifs
|
| 1261 |
+
seen = set()
|
| 1262 |
+
unique_candidates = []
|
| 1263 |
+
intrant_base = ""
|
| 1264 |
+
if result.intrant_utilise:
|
| 1265 |
+
intrant_base = result.intrant_utilise.split(",")[0].split("/")[0].strip().lower()
|
| 1266 |
+
|
| 1267 |
+
for c in candidates:
|
| 1268 |
+
key = (c["nom"], c["source"])
|
| 1269 |
+
if key in seen or c["nom"] == result.intrant_utilise:
|
| 1270 |
+
continue
|
| 1271 |
+
# Pour GFLI, vérifier que le candidat est pertinent
|
| 1272 |
+
if c["source"] == "GFLI" and not _is_name_match(matiere, c["nom"]):
|
| 1273 |
+
# Accepter quand même si ça matche le nom de base de l'intrant validé
|
| 1274 |
+
if intrant_base and _is_name_match(intrant_base, c["nom"]):
|
| 1275 |
+
pass # OK, même famille de produit
|
| 1276 |
+
elif matiere_en and _is_name_match(matiere_en, c["nom"]):
|
| 1277 |
+
pass # OK, match en anglais
|
| 1278 |
+
elif matiere_fr and _is_name_match(matiere_fr, c["nom"]):
|
| 1279 |
+
pass # OK, match en français
|
| 1280 |
+
else:
|
| 1281 |
+
continue # Faux positif
|
| 1282 |
+
seen.add(key)
|
| 1283 |
+
unique_candidates.append(c)
|
| 1284 |
+
|
| 1285 |
+
result.candidats_alternatifs = unique_candidates
|
| 1286 |
+
return result
|
llm_service.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
llm_service.py - Intégration avec Mistral AI pour :
|
| 3 |
+
1. Déterminer si une matière est brute ou transformée (via PDF CIR)
|
| 4 |
+
2. Trouver le nom correspondant dans GFLI/EcoALIM
|
| 5 |
+
3. Justifier le choix d'une valeur proche
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from mistralai import Mistral
|
| 11 |
+
|
| 12 |
+
import config
|
| 13 |
+
import data_loader
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _get_client() -> Mistral:
|
| 17 |
+
"""Crée un client Mistral."""
|
| 18 |
+
return Mistral(api_key=config.MISTRAL_API_KEY)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _chat(system_prompt: str, user_prompt: str) -> str:
|
| 22 |
+
"""Appel simple au LLM Mistral."""
|
| 23 |
+
client = _get_client()
|
| 24 |
+
response = client.chat.complete(
|
| 25 |
+
model=config.MISTRAL_MODEL,
|
| 26 |
+
messages=[
|
| 27 |
+
{"role": "system", "content": system_prompt},
|
| 28 |
+
{"role": "user", "content": user_prompt},
|
| 29 |
+
],
|
| 30 |
+
temperature=0.1,
|
| 31 |
+
max_tokens=2000,
|
| 32 |
+
)
|
| 33 |
+
return response.choices[0].message.content.strip()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _chat_powerful(system_prompt: str, user_prompt: str, temperature: float = 0.2) -> str:
|
| 37 |
+
"""Appel au LLM Mistral avec modèle plus puissant pour analyses complexes."""
|
| 38 |
+
client = _get_client()
|
| 39 |
+
response = client.chat.complete(
|
| 40 |
+
model=config.MISTRAL_MODEL_POWERFUL,
|
| 41 |
+
messages=[
|
| 42 |
+
{"role": "system", "content": system_prompt},
|
| 43 |
+
{"role": "user", "content": user_prompt},
|
| 44 |
+
],
|
| 45 |
+
temperature=temperature,
|
| 46 |
+
max_tokens=3000,
|
| 47 |
+
)
|
| 48 |
+
return response.choices[0].message.content.strip()
|
| 49 |
+
|
| 50 |
+
# ============================================================================
|
| 51 |
+
# 1. Déterminer si une matière est brute ou transformée
|
| 52 |
+
# ============================================================================
|
| 53 |
+
|
| 54 |
+
def determine_brut_ou_transforme(matiere_name: str) -> dict:
|
| 55 |
+
"""
|
| 56 |
+
Utilise le PDF CIR et Mistral pour déterminer si la matière est brute ou transformée.
|
| 57 |
+
Retourne: {"classification": "brut" | "transforme", "justification": "..."}
|
| 58 |
+
"""
|
| 59 |
+
pdf_excerpt = data_loader.get_pdf_excerpt(max_chars=12000)
|
| 60 |
+
|
| 61 |
+
system_prompt = """Tu es un expert en alimentation animale et en classification des matières premières.
|
| 62 |
+
Tu dois déterminer si une matière première est BRUTE (non transformée, directement issue de la culture/élevage)
|
| 63 |
+
ou TRANSFORMÉE (ayant subi un procédé de transformation : extraction, toastage, pressage, décorticage, etc.).
|
| 64 |
+
|
| 65 |
+
Règles :
|
| 66 |
+
- Une matière BRUTE est un intrant directement récolté : grain entier, graine, racine, fourrage.
|
| 67 |
+
Exemples : blé, orge, maïs grain, soja graine, luzerne (non déshydratée).
|
| 68 |
+
- Une matière TRANSFORMÉE est un coproduit ou un intrant ayant subi un process industriel.
|
| 69 |
+
Exemples : tourteau de soja, tourteau de colza, son de blé, huile de palme, farine de poisson,
|
| 70 |
+
drèches, pulpes, amidon, luzerne déshydratée, tourteau de tournesol décortiqué.
|
| 71 |
+
|
| 72 |
+
Si le nom contient des mots comme : tourteau, T., drèche, huile, farine, amidon, pulpe, son,
|
| 73 |
+
coproduit, expeller, extrait, déshydraté, décortiqué, presse, lécithine, mélasse, concentré,
|
| 74 |
+
gluten, protéine -> c'est TRANSFORMÉ.
|
| 75 |
+
|
| 76 |
+
Réponds UNIQUEMENT au format JSON :
|
| 77 |
+
{"classification": "brut" | "transforme", "justification": "explication courte"}"""
|
| 78 |
+
|
| 79 |
+
user_prompt = f"""Voici un extrait du Catalogue UE des Matières Premières pour contexte :
|
| 80 |
+
---
|
| 81 |
+
{pdf_excerpt[:6000]}
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
La matière première à classifier est : "{matiere_name}"
|
| 85 |
+
|
| 86 |
+
Détermine si cette matière est brute ou transformée."""
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
response = _chat(system_prompt, user_prompt)
|
| 90 |
+
# Parse JSON from response
|
| 91 |
+
import json
|
| 92 |
+
# Try to extract JSON from the response
|
| 93 |
+
json_start = response.find("{")
|
| 94 |
+
json_end = response.rfind("}") + 1
|
| 95 |
+
if json_start >= 0 and json_end > json_start:
|
| 96 |
+
result = json.loads(response[json_start:json_end])
|
| 97 |
+
return result
|
| 98 |
+
return {"classification": "transforme", "justification": "Classification par défaut (réponse non parsable)"}
|
| 99 |
+
except Exception as e:
|
| 100 |
+
# Fallback heuristique
|
| 101 |
+
return _heuristic_classification(matiere_name)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _heuristic_classification(matiere_name: str) -> dict:
|
| 105 |
+
"""Heuristique simple si le LLM n'est pas disponible."""
|
| 106 |
+
name_lower = matiere_name.lower().strip()
|
| 107 |
+
transformed_keywords = [
|
| 108 |
+
"tourteau", "t.", "t ", "drèche", "huile", "farine", "amidon",
|
| 109 |
+
"pulpe", "son ", "coproduit", "expeller", "extrait", "déshydrat",
|
| 110 |
+
"décortiq", "press", "lécithine", "mélasse", "concentré",
|
| 111 |
+
"gluten", "protéine", "toasté", "solubles", "issues",
|
| 112 |
+
]
|
| 113 |
+
for kw in transformed_keywords:
|
| 114 |
+
if kw in name_lower:
|
| 115 |
+
return {
|
| 116 |
+
"classification": "transforme",
|
| 117 |
+
"justification": f"Heuristique : le mot-clé '{kw}' indique un produit transformé.",
|
| 118 |
+
}
|
| 119 |
+
return {
|
| 120 |
+
"classification": "brut",
|
| 121 |
+
"justification": "Heuristique : aucun indicateur de transformation détecté, classifié comme brut.",
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ============================================================================
|
| 126 |
+
# 2. Trouver le nom correspondant dans GFLI ou EcoALIM
|
| 127 |
+
# ============================================================================
|
| 128 |
+
|
| 129 |
+
def translate_matiere_to_english(matiere_name: str) -> str:
|
| 130 |
+
"""Traduit un nom de matière première du français vers l'anglais pour GFLI."""
|
| 131 |
+
system_prompt = """Tu es un traducteur expert en alimentation animale.
|
| 132 |
+
Traduis le nom de matière première français en anglais technique utilisé dans les bases de données
|
| 133 |
+
d'alimentation animale (comme GFLI).
|
| 134 |
+
|
| 135 |
+
Traductions courantes :
|
| 136 |
+
- BLE / Blé → Wheat grain
|
| 137 |
+
- ORGE → Barley grain
|
| 138 |
+
- MAÏS → Maize grain / Corn grain
|
| 139 |
+
- T.TNSL / Tourteau de tournesol → Sunflower meal
|
| 140 |
+
- T. COLZA / Tourteau de colza → Rapeseed meal
|
| 141 |
+
- T. SOJA / Tourteau de soja → Soybean meal
|
| 142 |
+
- LUZERNE → Alfalfa
|
| 143 |
+
- COLZA → Rapeseed
|
| 144 |
+
- TOURNESOL → Sunflower
|
| 145 |
+
- POIS → Peas
|
| 146 |
+
- FEVEROLE → Faba beans / Broad beans
|
| 147 |
+
- SON → Bran
|
| 148 |
+
- DRÈCHE → Distillers grains
|
| 149 |
+
- PULPE → Pulp
|
| 150 |
+
- HUILE → Oil
|
| 151 |
+
- FARINE → Meal / Flour
|
| 152 |
+
- "DEC." / décortiqué → dehulled
|
| 153 |
+
- "EXPELLER" → expeller
|
| 154 |
+
- "48PB" / "48" → high protein / 48% protein
|
| 155 |
+
|
| 156 |
+
Réponds UNIQUEMENT avec la traduction anglaise, rien d'autre."""
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
return _chat(system_prompt, f"Traduis : {matiere_name}")
|
| 160 |
+
except Exception:
|
| 161 |
+
return matiere_name
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def translate_matiere_to_french(matiere_name: str) -> str:
|
| 165 |
+
"""Traduit un nom de matière première anglais vers le français pour EcoALIM."""
|
| 166 |
+
system_prompt = """Tu es un traducteur expert en alimentation animale.
|
| 167 |
+
Traduis le nom de matière première anglais en français technique utilisé dans les bases de données
|
| 168 |
+
d'alimentation animale (comme EcoALIM).
|
| 169 |
+
|
| 170 |
+
Traductions courantes :
|
| 171 |
+
- Wheat grain → Blé
|
| 172 |
+
- Barley grain → Orge
|
| 173 |
+
- Maize/Corn grain → Maïs
|
| 174 |
+
- Sunflower meal → Tourteau de tournesol
|
| 175 |
+
- Rapeseed meal → Tourteau de colza
|
| 176 |
+
- Soybean meal → Tourteau de soja
|
| 177 |
+
- Alfalfa → Luzerne
|
| 178 |
+
- Rapeseed → Colza
|
| 179 |
+
- Sunflower → Tournesol
|
| 180 |
+
- Peas → Pois
|
| 181 |
+
- Faba beans → Féverole
|
| 182 |
+
- Bran → Son
|
| 183 |
+
- Distillers grains → Drèche
|
| 184 |
+
- Pulp → Pulpe
|
| 185 |
+
- Oil → Huile
|
| 186 |
+
- Meal/Flour → Tourteau/Farine
|
| 187 |
+
- Dehulled → Décortiqué
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
user_prompt = f"""Traduis en français le nom suivant : "{matiere_name}".
|
| 191 |
+
Réponds uniquement par la traduction (pas d'explication)."""
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
response = _chat(system_prompt, user_prompt)
|
| 195 |
+
return response.strip().strip('"')
|
| 196 |
+
except Exception:
|
| 197 |
+
return matiere_name
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _prefilter_gfli_names(matiere: str, available_names: list) -> list:
|
| 201 |
+
"""Pré-filtre les noms GFLI par mots-clés pour réduire la liste envoyée au LLM."""
|
| 202 |
+
# Correspondances FR -> EN pour pré-filtrage
|
| 203 |
+
keyword_map = {
|
| 204 |
+
"ble": ["wheat"], "blé": ["wheat"],
|
| 205 |
+
"orge": ["barley"], "maïs": ["maize", "corn"], "mais": ["maize", "corn"],
|
| 206 |
+
"colza": ["rapeseed", "canola"], "tournesol": ["sunflower"],
|
| 207 |
+
"soja": ["soy", "soybean"], "luzerne": ["alfalfa"],
|
| 208 |
+
"pois": ["pea"], "féverole": ["faba", "broad bean"], "feverole": ["faba", "broad bean"],
|
| 209 |
+
"avoine": ["oat"], "seigle": ["rye"], "triticale": ["triticale"],
|
| 210 |
+
"lin": ["linseed", "flax"], "palme": ["palm"], "copra": ["coconut", "copra"],
|
| 211 |
+
"manioc": ["cassava"], "betterave": ["sugar beet", "beet"],
|
| 212 |
+
"pomme de terre": ["potato"], "riz": ["rice"],
|
| 213 |
+
"drèche": ["distiller"], "dreche": ["distiller"],
|
| 214 |
+
"pulpe": ["pulp"], "son": ["bran"], "gluten": ["gluten"],
|
| 215 |
+
"huile": ["oil"], "farine": ["meal", "flour"],
|
| 216 |
+
"tourteau": ["meal", "cake", "expeller"],
|
| 217 |
+
"graine": ["seed", "grain"],
|
| 218 |
+
"poisson": ["fish"], "viande": ["meat", "animal"],
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
matiere_lower = matiere.lower().strip()
|
| 222 |
+
search_terms = []
|
| 223 |
+
|
| 224 |
+
# Chercher des mots-clés dans le nom
|
| 225 |
+
for fr_key, en_keys in keyword_map.items():
|
| 226 |
+
if fr_key in matiere_lower:
|
| 227 |
+
search_terms.extend(en_keys)
|
| 228 |
+
|
| 229 |
+
if not search_terms:
|
| 230 |
+
return available_names[:150] # fallback
|
| 231 |
+
|
| 232 |
+
# Filtrer
|
| 233 |
+
filtered = []
|
| 234 |
+
for name in available_names:
|
| 235 |
+
name_lower = name.lower()
|
| 236 |
+
if any(term in name_lower for term in search_terms):
|
| 237 |
+
filtered.append(name)
|
| 238 |
+
|
| 239 |
+
return filtered if filtered else available_names[:150]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def find_matching_name_in_db(
|
| 243 |
+
matiere_name: str,
|
| 244 |
+
db_name: str = "GFLI",
|
| 245 |
+
) -> dict:
|
| 246 |
+
"""
|
| 247 |
+
Utilise Mistral pour trouver le meilleur match dans la base de données.
|
| 248 |
+
Retourne: {"matched_name": "...", "confidence": "haute|moyenne|basse", "justification": "..."}
|
| 249 |
+
"""
|
| 250 |
+
if db_name == "GFLI":
|
| 251 |
+
available_names = data_loader.get_gfli_base_products()
|
| 252 |
+
# Pré-filtrer avec les mots-clés traduits
|
| 253 |
+
filtered_names = _prefilter_gfli_names(matiere_name, available_names)
|
| 254 |
+
names_text = "\n".join(filtered_names[:150])
|
| 255 |
+
else:
|
| 256 |
+
available_names = data_loader.get_ecoalim_matieres()
|
| 257 |
+
names_text = "\n".join(available_names[:200])
|
| 258 |
+
|
| 259 |
+
system_prompt = f"""Tu es un expert en alimentation animale et en matières premières pour aliments composés.
|
| 260 |
+
Tu dois trouver dans la base de données {db_name} le nom le plus proche correspondant à une matière première donnée.
|
| 261 |
+
|
| 262 |
+
IMPORTANT :
|
| 263 |
+
- Tu dois répondre avec le nom EXACT tel qu'il apparaît dans la liste.
|
| 264 |
+
- Si aucune correspondance n'est trouvée, retourne "AUCUN".
|
| 265 |
+
- Donne ta confiance : haute (match exact ou quasi-exact), moyenne (même type de matière), basse (approximation).
|
| 266 |
+
|
| 267 |
+
Réponds UNIQUEMENT au format JSON :
|
| 268 |
+
{{"matched_name": "nom exact de la liste", "confidence": "haute|moyenne|basse", "justification": "explication"}}"""
|
| 269 |
+
|
| 270 |
+
user_prompt = f"""Matière première à chercher : "{matiere_name}"
|
| 271 |
+
|
| 272 |
+
Liste des matières disponibles dans {db_name} :
|
| 273 |
+
{names_text}
|
| 274 |
+
|
| 275 |
+
Trouve le meilleur match."""
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
response = _chat(system_prompt, user_prompt)
|
| 279 |
+
import json
|
| 280 |
+
json_start = response.find("{")
|
| 281 |
+
json_end = response.rfind("}") + 1
|
| 282 |
+
if json_start >= 0 and json_end > json_start:
|
| 283 |
+
return json.loads(response[json_start:json_end])
|
| 284 |
+
return {"matched_name": "AUCUN", "confidence": "basse", "justification": "Réponse non parsable"}
|
| 285 |
+
except Exception as e:
|
| 286 |
+
return {"matched_name": "AUCUN", "confidence": "basse", "justification": f"Erreur LLM : {e}"}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ============================================================================
|
| 290 |
+
# 3. Justifier le choix d'une valeur proche
|
| 291 |
+
# ============================================================================
|
| 292 |
+
|
| 293 |
+
def justify_alternative_value(
|
| 294 |
+
matiere_originale: str,
|
| 295 |
+
matiere_utilisee: str,
|
| 296 |
+
valeur: float,
|
| 297 |
+
source: str,
|
| 298 |
+
) -> str:
|
| 299 |
+
"""
|
| 300 |
+
Génère une justification par le LLM sur pourquoi on utilise la valeur
|
| 301 |
+
d'une matière alternative quand la matière exacte n'existe pas.
|
| 302 |
+
"""
|
| 303 |
+
system_prompt = """Tu es un expert en analyse du cycle de vie (ACV) et en alimentation animale.
|
| 304 |
+
Tu dois justifier pourquoi la valeur d'impact carbone d'une matière première alternative
|
| 305 |
+
a été choisie pour remplacer une matière première non trouvée dans les bases de données.
|
| 306 |
+
|
| 307 |
+
Donne une explication concise et technique (3-5 phrases max) en français.
|
| 308 |
+
Mentionne les similitudes agricoles, nutritionnelles ou de process qui justifient ce choix."""
|
| 309 |
+
|
| 310 |
+
user_prompt = f"""La matière première recherchée était : "{matiere_originale}"
|
| 311 |
+
Elle n'a pas été trouvée directement dans les bases de données.
|
| 312 |
+
|
| 313 |
+
La valeur utilisée est celle de : "{matiere_utilisee}"
|
| 314 |
+
Valeur d'impact carbone : {valeur:.4f} (source : {source})
|
| 315 |
+
|
| 316 |
+
Justifie ce choix de substitution."""
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
return _chat(system_prompt, user_prompt)
|
| 320 |
+
except Exception:
|
| 321 |
+
return (
|
| 322 |
+
f"Valeur de '{matiere_utilisee}' utilisée comme proxy pour '{matiere_originale}' "
|
| 323 |
+
f"car c'est la matière la plus proche disponible dans {source}."
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ============================================================================
|
| 328 |
+
# 4. Recherche intelligente avec fallback LLM
|
| 329 |
+
# ============================================================================
|
| 330 |
+
|
| 331 |
+
def smart_search_ecoalim(
|
| 332 |
+
matiere: str,
|
| 333 |
+
pays_production: Optional[str] = None,
|
| 334 |
+
pays_transformation: Optional[str] = None,
|
| 335 |
+
) -> Optional[dict]:
|
| 336 |
+
"""
|
| 337 |
+
Recherche intelligente dans EcoALIM : d'abord recherche directe,
|
| 338 |
+
puis si pas de résultat, utilise le LLM pour trouver un match.
|
| 339 |
+
Retourne un dict avec valeur, nom, source et éventuellement justification.
|
| 340 |
+
"""
|
| 341 |
+
# Tentative directe
|
| 342 |
+
result = data_loader.get_ecoalim_climate_value(matiere, pays_production, pays_transformation)
|
| 343 |
+
if result:
|
| 344 |
+
val, nom, source = result
|
| 345 |
+
# Valider que ce n'est pas un faux positif
|
| 346 |
+
if data_loader.is_name_match(matiere, nom):
|
| 347 |
+
return {
|
| 348 |
+
"valeur_kg_co2_eq": val,
|
| 349 |
+
"nom_intrant": nom,
|
| 350 |
+
"source": source,
|
| 351 |
+
"match_exact": True,
|
| 352 |
+
"justification": None,
|
| 353 |
+
}
|
| 354 |
+
# Faux positif — on continue vers le LLM
|
| 355 |
+
|
| 356 |
+
# Tentative avec traduction EN->FR
|
| 357 |
+
matiere_fr = translate_matiere_to_french(matiere)
|
| 358 |
+
if matiere_fr.lower() != matiere.lower():
|
| 359 |
+
result = data_loader.get_ecoalim_climate_value(matiere_fr, pays_production, pays_transformation)
|
| 360 |
+
if not result:
|
| 361 |
+
result = data_loader.get_ecoalim_climate_value(matiere_fr)
|
| 362 |
+
if result:
|
| 363 |
+
val, nom, source = result
|
| 364 |
+
if data_loader.is_name_match(matiere_fr, nom):
|
| 365 |
+
return {
|
| 366 |
+
"valeur_kg_co2_eq": val,
|
| 367 |
+
"nom_intrant": nom,
|
| 368 |
+
"source": source,
|
| 369 |
+
"match_exact": False,
|
| 370 |
+
"justification": f"Traduction automatique : '{matiere}' → '{matiere_fr}'",
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
# Tentative via LLM
|
| 374 |
+
match_info = find_matching_name_in_db(matiere, "ECOALIM")
|
| 375 |
+
if match_info.get("matched_name") and match_info["matched_name"] != "AUCUN":
|
| 376 |
+
matched_name = match_info["matched_name"]
|
| 377 |
+
result = data_loader.get_ecoalim_climate_value(matched_name, pays_production, pays_transformation)
|
| 378 |
+
if not result:
|
| 379 |
+
result = data_loader.get_ecoalim_climate_value(matched_name)
|
| 380 |
+
if result:
|
| 381 |
+
val, nom, source = result
|
| 382 |
+
justif = justify_alternative_value(matiere, nom, val, source)
|
| 383 |
+
return {
|
| 384 |
+
"valeur_kg_co2_eq": val,
|
| 385 |
+
"nom_intrant": nom,
|
| 386 |
+
"source": source,
|
| 387 |
+
"match_exact": False,
|
| 388 |
+
"justification": justif,
|
| 389 |
+
"llm_match_info": match_info,
|
| 390 |
+
}
|
| 391 |
+
return None
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def smart_search_gfli(
|
| 395 |
+
matiere: str,
|
| 396 |
+
country_iso: Optional[str] = None,
|
| 397 |
+
) -> Optional[dict]:
|
| 398 |
+
"""
|
| 399 |
+
Recherche intelligente dans GFLI : d'abord recherche directe,
|
| 400 |
+
puis traduction FR→EN, puis si pas de résultat, utilise le LLM pour trouver un match.
|
| 401 |
+
"""
|
| 402 |
+
# Tentative directe
|
| 403 |
+
result = data_loader.get_gfli_climate_value(matiere, country_iso)
|
| 404 |
+
if result:
|
| 405 |
+
val, nom, source = result
|
| 406 |
+
# Valider que ce n'est pas un faux positif (ex: "blé" → "blend")
|
| 407 |
+
if data_loader.is_name_match(matiere, nom):
|
| 408 |
+
return {
|
| 409 |
+
"valeur_kg_co2_eq_par_tonne": val,
|
| 410 |
+
"nom_intrant": nom,
|
| 411 |
+
"source": source,
|
| 412 |
+
"match_exact": True,
|
| 413 |
+
"justification": None,
|
| 414 |
+
}
|
| 415 |
+
# Faux positif — on continue vers la traduction / LLM
|
| 416 |
+
|
| 417 |
+
# Tentative avec traduction FR→EN
|
| 418 |
+
matiere_en = translate_matiere_to_english(matiere)
|
| 419 |
+
if matiere_en.lower() != matiere.lower():
|
| 420 |
+
result = data_loader.get_gfli_climate_value(matiere_en, country_iso)
|
| 421 |
+
if result:
|
| 422 |
+
val, nom, source = result
|
| 423 |
+
# Traduction nécessaire → pas un match exact
|
| 424 |
+
if data_loader.is_name_match(matiere_en, nom):
|
| 425 |
+
return {
|
| 426 |
+
"valeur_kg_co2_eq_par_tonne": val,
|
| 427 |
+
"nom_intrant": nom,
|
| 428 |
+
"source": source,
|
| 429 |
+
"match_exact": False,
|
| 430 |
+
"justification": f"Traduction automatique : '{matiere}' → '{matiere_en}'",
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
# Tentative avec traduction EN->FR puis FR->EN (double sens)
|
| 434 |
+
matiere_fr = translate_matiere_to_french(matiere)
|
| 435 |
+
if matiere_fr.lower() != matiere.lower():
|
| 436 |
+
matiere_en2 = translate_matiere_to_english(matiere_fr)
|
| 437 |
+
if matiere_en2.lower() != matiere.lower() and matiere_en2.lower() != matiere_en.lower():
|
| 438 |
+
result = data_loader.get_gfli_climate_value(matiere_en2, country_iso)
|
| 439 |
+
if result:
|
| 440 |
+
val, nom, source = result
|
| 441 |
+
if data_loader.is_name_match(matiere_en2, nom):
|
| 442 |
+
return {
|
| 443 |
+
"valeur_kg_co2_eq_par_tonne": val,
|
| 444 |
+
"nom_intrant": nom,
|
| 445 |
+
"source": source,
|
| 446 |
+
"match_exact": False,
|
| 447 |
+
"justification": f"Traduction automatique : '{matiere}' → '{matiere_fr}' → '{matiere_en2}'",
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
# Tentative via LLM
|
| 451 |
+
match_info = find_matching_name_in_db(matiere, "GFLI")
|
| 452 |
+
if match_info.get("matched_name") and match_info["matched_name"] != "AUCUN":
|
| 453 |
+
matched_name = match_info["matched_name"]
|
| 454 |
+
result = data_loader.get_gfli_climate_value(matched_name, country_iso)
|
| 455 |
+
if not result and country_iso:
|
| 456 |
+
# Try without country filter
|
| 457 |
+
result = data_loader.get_gfli_climate_value(matched_name)
|
| 458 |
+
if result:
|
| 459 |
+
val, nom, source = result
|
| 460 |
+
justif = justify_alternative_value(matiere, nom, val, source)
|
| 461 |
+
return {
|
| 462 |
+
"valeur_kg_co2_eq_par_tonne": val,
|
| 463 |
+
"nom_intrant": nom,
|
| 464 |
+
"source": source,
|
| 465 |
+
"match_exact": False,
|
| 466 |
+
"justification": justif,
|
| 467 |
+
"llm_match_info": match_info,
|
| 468 |
+
}
|
| 469 |
+
return None
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def rank_candidates(matiere: str, candidates: list[str]) -> dict:
|
| 473 |
+
"""
|
| 474 |
+
Demande au LLM quel candidat est le plus pertinent et pourquoi.
|
| 475 |
+
Retourne: {"best_name": "...", "reasoning": "..."}
|
| 476 |
+
"""
|
| 477 |
+
if not candidates:
|
| 478 |
+
return {"best_name": "", "reasoning": ""}
|
| 479 |
+
|
| 480 |
+
# Garder une taille raisonnable pour le prompt
|
| 481 |
+
max_items = 40
|
| 482 |
+
truncated = len(candidates) > max_items
|
| 483 |
+
cand_list = candidates[:max_items]
|
| 484 |
+
|
| 485 |
+
system_prompt = """Tu es un expert en alimentation animale et en ACV.
|
| 486 |
+
Tu dois choisir le candidat le plus pertinent parmi une liste, en tenant compte
|
| 487 |
+
des synonymes et des langues (ex: tournesol = sunflower).
|
| 488 |
+
|
| 489 |
+
Réponds UNIQUEMENT au format JSON :
|
| 490 |
+
{"best_name": "...", "reasoning": "..."}
|
| 491 |
+
"""
|
| 492 |
+
|
| 493 |
+
user_prompt = f"""Matière recherchée : "{matiere}"
|
| 494 |
+
|
| 495 |
+
Liste de candidats :
|
| 496 |
+
{chr(10).join('- ' + c for c in cand_list)}
|
| 497 |
+
|
| 498 |
+
Choisis le meilleur candidat et explique brièvement (2-4 phrases)."""
|
| 499 |
+
|
| 500 |
+
if truncated:
|
| 501 |
+
user_prompt += "\n\nNote: la liste a été tronquée pour la requête."
|
| 502 |
+
|
| 503 |
+
try:
|
| 504 |
+
response = _chat(system_prompt, user_prompt)
|
| 505 |
+
import json
|
| 506 |
+
json_start = response.find("{")
|
| 507 |
+
json_end = response.rfind("}") + 1
|
| 508 |
+
parsed = json.loads(response[json_start:json_end])
|
| 509 |
+
return {
|
| 510 |
+
"best_name": parsed.get("best_name", ""),
|
| 511 |
+
"reasoning": parsed.get("reasoning", ""),
|
| 512 |
+
}
|
| 513 |
+
except Exception:
|
| 514 |
+
return {"best_name": "", "reasoning": ""}
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def find_similar_material(matiere: str, db_name: str = "GFLI") -> Optional[dict]:
|
| 518 |
+
"""
|
| 519 |
+
Quand aucune matière exacte n'est trouvée, cherche une matière AVEC UN IMPACT CARBONE SIMILAIRE
|
| 520 |
+
(itinéraire technique et profil nutritionnel proches).
|
| 521 |
+
|
| 522 |
+
Retourne: {"similar_name": "...", "impact_kg_co2": value, "source": "...", "reasoning": "..."}
|
| 523 |
+
ou None si aucune suggestion
|
| 524 |
+
"""
|
| 525 |
+
result = find_alternative_materials(matiere, db_name)
|
| 526 |
+
if result and result.get("combined"):
|
| 527 |
+
alt = result["combined"]
|
| 528 |
+
return {
|
| 529 |
+
"similar_name": alt["name"],
|
| 530 |
+
"impact_kg_co2": alt["impact"],
|
| 531 |
+
"source": alt["source"],
|
| 532 |
+
"reasoning": alt["reasoning"],
|
| 533 |
+
}
|
| 534 |
+
return None
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def find_alternative_materials(matiere: str, db_name: str = "GFLI", country_hint: Optional[str] = None) -> Optional[dict]:
|
| 538 |
+
"""
|
| 539 |
+
Propose 4 alternatives quand une matière exacte n'est pas trouvée :
|
| 540 |
+
1. itinerary : même itinéraire technique (processus similaire, impact comparable)
|
| 541 |
+
2. locality : même localité/région de production (ou celle fournie en country_hint)
|
| 542 |
+
3. form : même forme structurelle (graine → graine, oléo → oléo, etc.)
|
| 543 |
+
4. combined : meilleur compromis réfléchi des 3 critères
|
| 544 |
+
|
| 545 |
+
Args:
|
| 546 |
+
matiere: Nom de la matière non trouvée
|
| 547 |
+
db_name: "GFLI" ou "ECOALIM"
|
| 548 |
+
country_hint: Pays optionnel pour guider la proposition de localité
|
| 549 |
+
|
| 550 |
+
Retourne: {
|
| 551 |
+
"itinerary": {"name": "...", "impact": value, "source": "...", "reasoning": "..."},
|
| 552 |
+
"locality": {...},
|
| 553 |
+
"form": {...},
|
| 554 |
+
"combined": {...}
|
| 555 |
+
}
|
| 556 |
+
ou None si erreur
|
| 557 |
+
"""
|
| 558 |
+
if db_name == "GFLI":
|
| 559 |
+
# Récupérer tous les produits GFLI avec leurs valeurs
|
| 560 |
+
all_products = data_loader.get_gfli_base_products()
|
| 561 |
+
products_with_values = []
|
| 562 |
+
for prod in all_products[:100]:
|
| 563 |
+
val_tuple = data_loader.get_gfli_climate_value(prod)
|
| 564 |
+
if val_tuple:
|
| 565 |
+
val, nom, source = val_tuple
|
| 566 |
+
products_with_values.append({
|
| 567 |
+
"name": nom,
|
| 568 |
+
"impact": val,
|
| 569 |
+
"source": source,
|
| 570 |
+
})
|
| 571 |
+
|
| 572 |
+
products_text = "\n".join(
|
| 573 |
+
f"- {p['name']}: {p['impact']:.2f} kg CO2 eq/t"
|
| 574 |
+
for p in products_with_values[:50]
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
system_prompt = """Tu es un expert en alimentation animale, biologie végétale, ACV et sourcing de matières premières.
|
| 578 |
+
Une matière première n'a pas été trouvée dans la base GFLI.
|
| 579 |
+
Tu dois proposer 4 alternatives avec des critères différents :
|
| 580 |
+
|
| 581 |
+
1. ITINERARY (itinéraire technique) : même processus agricole/industriel, même impact carbone comparable
|
| 582 |
+
→ Même type de culture (céréale, légumineuse, etc.), même irrigation, même type de récolte
|
| 583 |
+
|
| 584 |
+
2. LOCALITY (localité) : même région/zone géographique de production (FR, BR, etc.)
|
| 585 |
+
→ Même pays/région, même climat agricole, même disponibilité
|
| 586 |
+
|
| 587 |
+
3. FORM (forme structurelle) : MÊME GENRE BOTANIQUE OU TRÈS PROCHE (priorité au genre)
|
| 588 |
+
→ Épautre (Triticum dicoccum) = BLÉS/Wheat (genres Triticum, pas Hordeum/Barley)
|
| 589 |
+
→ Orge (Hordeum vulgare) = rester Orge/Barley
|
| 590 |
+
→ Pois (Pisum) = Pois/Pea, pas Broad beans ou autre légumineuse
|
| 591 |
+
→ Graine générique → propose autres graines du MÊME genre si possible
|
| 592 |
+
→ Légumineuse → autres légumineuses du même genre
|
| 593 |
+
→ RÈGLE D'OR : respecter le genre botanique (Triticum ≠ Hordeum) !
|
| 594 |
+
|
| 595 |
+
4. COMBINED (combo réfléchi) : MEILLEUR choix qui combine les 3 critères de manière cohérente
|
| 596 |
+
→ OBLIGATOIRE : doit toujours avoir une réponse
|
| 597 |
+
→ Souvent c'est une alternative qui balance bien itinerary+locality
|
| 598 |
+
→ Si pas de perfect mix, choisir celui avec le meilleur itinerary + proche géographiquement
|
| 599 |
+
|
| 600 |
+
Les valeurs en kg CO2 eq/t t'aident à évaluer les impacts.
|
| 601 |
+
|
| 602 |
+
⚠️ IMPORTANT :
|
| 603 |
+
- Retourne SEULEMENT les noms qui existent dans la liste
|
| 604 |
+
- combined DOIT TOUJOURS avoir une valeur (ne pas le laisser vide/null)
|
| 605 |
+
- FORM : PRIORITÉ stricte au genre botanique (Triticum→Wheat, Hordeum→Barley, Pisum→Pea, etc.)
|
| 606 |
+
|
| 607 |
+
Réponds UNIQUEMENT au format JSON :
|
| 608 |
+
{
|
| 609 |
+
"itinerary": {"name": "nom exact", "reasoning": "raison technique"},
|
| 610 |
+
"locality": {"name": "nom exact", "reasoning": "raison géographique"},
|
| 611 |
+
"form": {"name": "nom exact", "reasoning": "raison structurelle avec même genre botanique"},
|
| 612 |
+
"combined": {"name": "nom exact", "reasoning": "raison du meilleur compromis"}
|
| 613 |
+
}"""
|
| 614 |
+
|
| 615 |
+
user_prompt = f"""Matière non trouvée : "{matiere}"
|
| 616 |
+
|
| 617 |
+
Produits GFLI disponibles :
|
| 618 |
+
{products_text}
|
| 619 |
+
|
| 620 |
+
Propose 4 alternatives avec les 4 critères différents.
|
| 621 |
+
⚠️ CRITICAL : Si la matière est épautre/blé (Triticum), propose un WHEAT (genre Triticum), PAS d'orge/barley !
|
| 622 |
+
⚠️ IMPORTANT : combined DOIT TOUJOURS avoir une valeur (jamais null/vide) !"""
|
| 623 |
+
if country_hint:
|
| 624 |
+
user_prompt += f"\n⚠️ LOCALITÉ : Pays spécifié = {country_hint}. Privilégie une alternative produite dans ce pays ou proche (même région)."
|
| 625 |
+
|
| 626 |
+
else: # EcoALIM
|
| 627 |
+
all_products = data_loader.get_ecoalim_matieres()
|
| 628 |
+
products_with_values = []
|
| 629 |
+
for prod in all_products[:100]:
|
| 630 |
+
val_tuple = data_loader.get_ecoalim_climate_value(prod)
|
| 631 |
+
if val_tuple:
|
| 632 |
+
val, nom, source = val_tuple
|
| 633 |
+
products_with_values.append({
|
| 634 |
+
"name": nom,
|
| 635 |
+
"impact": val * 1000,
|
| 636 |
+
"source": source,
|
| 637 |
+
})
|
| 638 |
+
|
| 639 |
+
products_text = "\n".join(
|
| 640 |
+
f"- {p['name']}: {p['impact']:.2f} kg CO2 eq/t"
|
| 641 |
+
for p in products_with_values[:50]
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
system_prompt = """Tu es un expert en alimentation animale, ACV et sourcing.
|
| 645 |
+
Une matière première n'a pas été trouvée dans EcoALIM.
|
| 646 |
+
Propose 4 alternatives :
|
| 647 |
+
1. ITINERARY : même itinéraire technique/process
|
| 648 |
+
2. LOCALITY : même provenance géographique
|
| 649 |
+
3. FORM : même catégorie structurelle
|
| 650 |
+
4. COMBINED : meilleur compromis réfléchi
|
| 651 |
+
|
| 652 |
+
Réponds UNIQUEMENT au format JSON avec les 4 alternatives."""
|
| 653 |
+
|
| 654 |
+
user_prompt = f"""Matière non trouvée : "{matiere}"
|
| 655 |
+
|
| 656 |
+
Produits disponibles :
|
| 657 |
+
{products_text}
|
| 658 |
+
|
| 659 |
+
Propose 4 alternatives avec les 4 critères."""
|
| 660 |
+
if country_hint:
|
| 661 |
+
user_prompt += f"\n⚠️ LOCALITÉ : Pays spécifié = {country_hint}. Privilégie une alternative produite dans ce pays ou proche (même région)."
|
| 662 |
+
|
| 663 |
+
try:
|
| 664 |
+
response = _chat_powerful(system_prompt, user_prompt, temperature=0.3)
|
| 665 |
+
import json
|
| 666 |
+
json_start = response.find("{")
|
| 667 |
+
json_end = response.rfind("}") + 1
|
| 668 |
+
parsed = json.loads(response[json_start:json_end])
|
| 669 |
+
|
| 670 |
+
result_dict = {}
|
| 671 |
+
|
| 672 |
+
for criterion in ["itinerary", "locality", "form", "combined"]:
|
| 673 |
+
criterion_data = parsed.get(criterion, {})
|
| 674 |
+
similar_name = criterion_data.get("name")
|
| 675 |
+
reasoning = criterion_data.get("reasoning", "")
|
| 676 |
+
|
| 677 |
+
if not similar_name or similar_name.lower() == "null":
|
| 678 |
+
result_dict[criterion] = None
|
| 679 |
+
continue
|
| 680 |
+
|
| 681 |
+
# Récupérer la valeur de la matière
|
| 682 |
+
if db_name == "GFLI":
|
| 683 |
+
val_tuple = data_loader.get_gfli_climate_value(similar_name)
|
| 684 |
+
if val_tuple:
|
| 685 |
+
val, nom, source = val_tuple
|
| 686 |
+
result_dict[criterion] = {
|
| 687 |
+
"name": nom,
|
| 688 |
+
"impact": val,
|
| 689 |
+
"source": source,
|
| 690 |
+
"reasoning": reasoning,
|
| 691 |
+
}
|
| 692 |
+
else: # EcoALIM
|
| 693 |
+
val_tuple = data_loader.get_ecoalim_climate_value(similar_name)
|
| 694 |
+
if val_tuple:
|
| 695 |
+
val, nom, source = val_tuple
|
| 696 |
+
result_dict[criterion] = {
|
| 697 |
+
"name": nom,
|
| 698 |
+
"impact": val,
|
| 699 |
+
"source": source,
|
| 700 |
+
"reasoning": reasoning,
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
# Fallback pour combined : si vide, utiliser itinerary (meilleur impact technique)
|
| 704 |
+
if not result_dict.get("combined") and result_dict.get("itinerary"):
|
| 705 |
+
result_dict["combined"] = {
|
| 706 |
+
"name": result_dict["itinerary"]["name"],
|
| 707 |
+
"impact": result_dict["itinerary"]["impact"],
|
| 708 |
+
"source": result_dict["itinerary"]["source"],
|
| 709 |
+
"reasoning": f"Meilleur compromis technique : {result_dict['itinerary']['reasoning']}"
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
if any(result_dict.values()):
|
| 713 |
+
return result_dict
|
| 714 |
+
return None
|
| 715 |
+
|
| 716 |
+
except Exception as e:
|
| 717 |
+
return None
|
logigramme.json
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"titre": "LOGIGRAMME POUR UNE MP VEGETALE ET ANIMALE",
|
| 4 |
+
"exclusion": "HORS PRODUIT DERIVE DU SOJA",
|
| 5 |
+
"description": "Logigramme d'aide pour faciliter l'application du Guide de calcul de l'impact carbone des aliments composés, construit par le GT Carbone.",
|
| 6 |
+
"regles_generales": [
|
| 7 |
+
"Le choix des valeurs par défaut de facteur d'émission des intrants relève de la compétence et de la responsabilité des entreprises, qui doivent pouvoir les justifier.",
|
| 8 |
+
"Il faut attribuer une valeur de facteur d'émission à tous les intrants.",
|
| 9 |
+
"Si provenance inconnue : retenir le facteur d'émission le plus défavorable.",
|
| 10 |
+
"Si provenance connue mais donnée précise inexistante : prendre la donnée générique disponible la plus pertinente.",
|
| 11 |
+
"Si absence dans GFLI ou ECOALIM : possibilité d'utiliser d'autres bases reconnues (ex: Agribalyse) à condition de justifier le choix."
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
"flowchart": {
|
| 15 |
+
"start_node": "node_1",
|
| 16 |
+
"nodes": [
|
| 17 |
+
{
|
| 18 |
+
"id": "node_1",
|
| 19 |
+
"type": "question",
|
| 20 |
+
"text": "Connaissez-vous l'endroit où l'intrant a été cultivé ou produit ?",
|
| 21 |
+
"options": [
|
| 22 |
+
{
|
| 23 |
+
"label": "Je ne sais pas où l'intrant a été cultivé/produit",
|
| 24 |
+
"next_node": "node_2"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"label": "Je sais où l'intrant a été cultivé/produit",
|
| 28 |
+
"next_node": "node_3"
|
| 29 |
+
}
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "node_2",
|
| 34 |
+
"type": "question",
|
| 35 |
+
"text": "Quel est le niveau de transformation de l'intrant ?",
|
| 36 |
+
"options": [
|
| 37 |
+
{
|
| 38 |
+
"label": "Il s'agit d'un intrant brut/non transformé",
|
| 39 |
+
"next_node": "node_4"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"label": "Il s'agit d'un coproduit/d'un intrant transformé",
|
| 43 |
+
"next_node": "node_5"
|
| 44 |
+
}
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "node_3",
|
| 49 |
+
"type": "question",
|
| 50 |
+
"text": "Quel est le niveau de transformation de l'intrant ?",
|
| 51 |
+
"options": [
|
| 52 |
+
{
|
| 53 |
+
"label": "Il s'agit d'un intrant brut/non transformé",
|
| 54 |
+
"next_node": "node_6"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"label": "Il s'agit d'un coproduit/d'un intrant transformé",
|
| 58 |
+
"next_node": "node_7"
|
| 59 |
+
}
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "node_4",
|
| 64 |
+
"type": "resultat",
|
| 65 |
+
"actions_priorisees": [
|
| 66 |
+
"1. Je prends la valeur la plus défavorable pour l'intrant dans le GFLI",
|
| 67 |
+
"2. Si elle n'existe pas dans le GFLI, je prends la valeur pour l'intrant la plus défavorable dans ECOALIM",
|
| 68 |
+
"3. Si la valeur n'existe pas, j'utilise la valeur GFLI pour l'intrant qui a le schéma cultural le plus proche"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "node_5",
|
| 73 |
+
"type": "resultat",
|
| 74 |
+
"actions_priorisees": [
|
| 75 |
+
"1. Je prends la valeur la plus défavorable pour l'intrant transformé dans le GFLI",
|
| 76 |
+
"2. Si elle n'existe pas dans le GFLI, je prends la valeur pour l'intrant la plus défavorable dans ECOALIM",
|
| 77 |
+
"3. Si la valeur n'existe pas, j'utilise la valeur GFLI pour l'intrant transformé qui a le schéma cultural ou le process le plus proche"
|
| 78 |
+
]
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": "node_6",
|
| 82 |
+
"type": "question",
|
| 83 |
+
"text": "Où l'intrant brut a-t-il été cultivé ?",
|
| 84 |
+
"options": [
|
| 85 |
+
{
|
| 86 |
+
"label": "L'intrant est cultivé en France",
|
| 87 |
+
"next_node": "node_8"
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"label": "L'intrant n'a pas été cultivé en France",
|
| 91 |
+
"next_node": "node_9"
|
| 92 |
+
}
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "node_7",
|
| 97 |
+
"type": "question",
|
| 98 |
+
"text": "Où l'intrant a-t-il été transformé et d'où provient la matière première brute ?",
|
| 99 |
+
"options": [
|
| 100 |
+
{
|
| 101 |
+
"label": "L'intrant a été transformé en France à partir d'une matière première brute française",
|
| 102 |
+
"next_node": "node_10"
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"label": "L'intrant est transformé en France mais je ne connais pas la provenance de l'intrant brut ou l'intrant brut ne provient pas de France",
|
| 106 |
+
"next_node": "node_11"
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"label": "L'intrant est transformé hors France",
|
| 110 |
+
"next_node": "node_12"
|
| 111 |
+
}
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"id": "node_8",
|
| 116 |
+
"type": "resultat",
|
| 117 |
+
"actions_priorisees": [
|
| 118 |
+
"1. Je prends la valeur pour l'intrant dans ECOALIM",
|
| 119 |
+
"2. Si elle n'existe pas, je prends la valeur de l'intrant dans le GFLI",
|
| 120 |
+
"3. Si la valeur n'existe pas, je prends une donnée d'un intrant qui a la pratique culturale la plus proche dans ECOALIM"
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"id": "node_9",
|
| 125 |
+
"type": "resultat",
|
| 126 |
+
"actions_priorisees": [
|
| 127 |
+
"1. Je prends la valeur pour l'intrant du pays correspondant dans le GFLI",
|
| 128 |
+
"2. Si la valeur n'existe pas : Je prends la valeur Mix Européen (RER) du GFLI si l'intrant provient d'Europe. Je prends la valeur du Mix Monde (GLO) du GFLI si l'intrant vient d'un autre continent",
|
| 129 |
+
"3. Si la valeur n'existe pas, je prends la valeur pour l'intrant correspondant dans ECOALIM"
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"id": "node_10",
|
| 134 |
+
"type": "resultat",
|
| 135 |
+
"actions_priorisees": [
|
| 136 |
+
"1. Je prends la valeur correspondant à cet intrant transformé dans ECOALIM",
|
| 137 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process de transformation, je pars de la valeur pour l'intrant brut dans ECOALIM et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, j'utilise la valeur GFLI si elle existe",
|
| 138 |
+
"3. Si cela n'est pas possible, je prends la valeur d'un intrant qui a le process le plus proche dans ECOALIM"
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"id": "node_11",
|
| 143 |
+
"type": "resultat",
|
| 144 |
+
"actions_priorisees": [
|
| 145 |
+
"1. Je prends la valeur France indiquée pour l'intrant dans le GFLI",
|
| 146 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process, je pars de la valeur pour l'intrant brut dans le GFLI et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, je prends la valeur GFLI du Mix Européen (RER)",
|
| 147 |
+
"3. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
|
| 148 |
+
"4. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
| 149 |
+
]
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"id": "node_12",
|
| 153 |
+
"type": "resultat",
|
| 154 |
+
"actions_priorisees": [
|
| 155 |
+
"1. Je prends la valeur GFLI du pays correspondant",
|
| 156 |
+
"2. Si la valeur n'existe pas : A/ Si je connais de manière fiable l'impact du process, je pars de la valeur pour la MP brute dans le GFLI et j'ajoute l'impact du process. B/ Si je ne connais pas de manière fiable l'impact du process, je prends la valeur GFLI du Mix Européen (RER) si l'intrant provient d'Europe et la valeur du Mix Monde (GLO) si l'intrant vient d'un autre continent",
|
| 157 |
+
"3. Si cela n'est pas possible, je prends la valeur pour l'intrant correspondant dans ECOALIM",
|
| 158 |
+
"4. Si la valeur n'existe pas, je prends la valeur d'un intrant qui a la pratique culturale la plus proche dans le GFLI"
|
| 159 |
+
]
|
| 160 |
+
}
|
| 161 |
+
]
|
| 162 |
+
}
|
| 163 |
+
}
|
logigramme_mineral.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"titre": "LOGIGRAMME POUR UNE MP MINERALE, UN MICRO INGREDIENT OU UN ADDITIF ",
|
| 4 |
+
"description": "Ces Logigrammes ont été construits pour faciliter l'application du Guide de calcul de l'impact carbone des aliments composés is constituent un outil d'aide, construit par le GT Carbone réunissant des professionnels des syndicats. [cite: 68]",
|
| 5 |
+
"regles_generales": [
|
| 6 |
+
"Le choix des valeurs précises de facteur d'émission des intrants relève de la compétence et de la responsabilité des entreprises. [cite: 69, 70]",
|
| 7 |
+
"A ce titre, elles doivent être en mesure de justifier les choix effectués. [cite: 71]",
|
| 8 |
+
"La bonne application du guide de calcul de l'impact carbone impose d'attribuer une valeur de facteur d'émission à tous les intrants. [cite: 72]",
|
| 9 |
+
"Lorsque l'entreprise ne connait pas la provenance d'un intrant, il conviendra de retenir par défaut le facteur d'émission le plus défavorable de l'intrant en question. [cite: 73]",
|
| 10 |
+
"Lorsque l'entreprise connait la provenance de l'intrant mais que la donnée précise n'existe pas, il conviendra de prendre la donnée générique disponible la plus pertinente. [cite: 74]",
|
| 11 |
+
"Lorsque les données ne sont pas présentes dans la base de données GFLI ou ECOALIM, les opérateurs peuvent s'orienter vers d'autres bases reconnues (tel agribalyse par exemple) dans la mesure où ils sont en capacité de jusitifier le choix effectué. [cite: 75]"
|
| 12 |
+
],
|
| 13 |
+
"point_entree": "Ma MP est une MP minérale, un micro ingrédient ou un additif [cite: 76, 77, 78, 79, 80, 81]"
|
| 14 |
+
},
|
| 15 |
+
"flowchart": {
|
| 16 |
+
"start_node": "node_1",
|
| 17 |
+
"nodes": [
|
| 18 |
+
{
|
| 19 |
+
"id": "node_1",
|
| 20 |
+
"type": "question",
|
| 21 |
+
"text": "Connaissez-vous l'origine de l'intrant (pays de production) ?",
|
| 22 |
+
"options": [
|
| 23 |
+
{
|
| 24 |
+
"label": "Je connais l'origine de l'intrant (pays de production) [cite: 82, 83]",
|
| 25 |
+
"next_node": "node_2"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"label": "Je ne connais pas l'origine de l'intrant [cite: 105]",
|
| 29 |
+
"next_node": "node_3"
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"id": "node_2",
|
| 35 |
+
"type": "question",
|
| 36 |
+
"text": "Dans quelle(s) base(s) de données l'intrant est-il présent ?",
|
| 37 |
+
"options": [
|
| 38 |
+
{
|
| 39 |
+
"label": "Si l'intrant est présent dans le GFLI [cite: 84]",
|
| 40 |
+
"next_node": "node_4"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"label": "Si l'intrant n'est pas présent dans le GFLI mais est présent dans ECOALIM [cite: 85]",
|
| 44 |
+
"next_node": "node_5"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"label": "Si l'intrant n'est présent ni dans GFLI ni dans ECOALIM [cite: 86]",
|
| 48 |
+
"next_node": "node_6"
|
| 49 |
+
}
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "node_3",
|
| 54 |
+
"type": "resultat",
|
| 55 |
+
"actions_priorisees": [
|
| 56 |
+
"1. J'utilise la donnée de l'intrant la plus défavorable dans le GFLI [cite: 106]",
|
| 57 |
+
"2. Si l'intrant n'est pas dans le GFLI, j'utilise la donnée de l'intrant la plus défavorable dans ECOALIM [cite: 107]",
|
| 58 |
+
"3. En dernier recours, je prends la donnée Total Minerals, Additives, Vitamins dans GFLI [cite: 108]"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"id": "node_4",
|
| 63 |
+
"type": "resultat",
|
| 64 |
+
"actions_priorisees": [
|
| 65 |
+
"1. J'utilise la donnée dans le GFLI s'il y a une valeur précise pour le couple intrant/pays (valeur Espagne pour un intrant venant d'Espagne) [cite: 87]",
|
| 66 |
+
"2. S'il n'y a pas de valeur précise pour le couple intrant/pays dans le GFLI, je prends la donnée ECOALIM précise pour le couple intrant/pays (valeur Espagne pour un intrant venant d'Espagne) [cite: 90]",
|
| 67 |
+
"3. Si elle n'existe pas, je prends la donnée de l'intrant dans le GFLI pour une autre provenance : En 1ère intention, je prends la valeur du continent (valeur Europe pour un intrant provenant d'Espagne) [cite: 95, 96, 97]",
|
| 68 |
+
"4. Si ce n'est pas possible, je prends la valeur d'un pays du même continent (si plusieurs valeurs sont disponibles, je prends la plus défavorable) dans le GFLI (ex: valeur Portugal par exemple pour un intrant venant d'Espagne) [cite: 98, 99]",
|
| 69 |
+
"5. Si cela n'est pas possible, je prends la valeur monde dans GFLI [cite: 101]",
|
| 70 |
+
"6. Si cela n'est pas possible, je prends la valeur pour un autre pays, qui n'est pas sur le même continent, dans le GFLI. S'il existe plusieurs options, je prends la plus défavorable. [cite: 104]"
|
| 71 |
+
]
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"id": "node_5",
|
| 75 |
+
"type": "resultat",
|
| 76 |
+
"actions_priorisees": [
|
| 77 |
+
"1. Je prends la donnée Ecoalim précise pour le couple intrant/pays si elle existe (valeur Espagne pour un intrant venant d'Espagne) [cite: 88]",
|
| 78 |
+
"2. Si la valeur n'existe pas, je prends la donnée de l'intrant dans ECOALIM pour une autre provenance : En 1ère intention, je prends la valeur du continent (valeur Europe pour un intrant provenant d'Espagne) [cite: 91, 92]",
|
| 79 |
+
"3. Si ce n'est pas possible, je prends la valeur d'un pays du même continent (si plusieurs valeurs sont disponibles, je prends la plus défavorable) dans Ecoalim (ex: valeur Portugal par exemple pour un intrant venant d'Espagne) [cite: 93, 94]",
|
| 80 |
+
"4. Si cela n'est pas possible, je prends la valeur monde dans Ecoalim [cite: 100]",
|
| 81 |
+
"5. Si cela n'est pas possible, je prends la valeur pour un autre pays, qui n'est pas sur le même continent, dans Ecoalim. S'il existe plusieurs options, je prends la valeur la plus défavorable [cite: 102, 103]"
|
| 82 |
+
]
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"id": "node_6",
|
| 86 |
+
"type": "resultat",
|
| 87 |
+
"actions_priorisees": [
|
| 88 |
+
"1. Je prends la donnée Total Minerals, Additives, Vitamins dans GFLI [cite: 89]"
|
| 89 |
+
]
|
| 90 |
+
}
|
| 91 |
+
]
|
| 92 |
+
}
|
| 93 |
+
}
|
logigramme_soja.json
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"titre": "LOGIGRAMME POUR UNE MP DERIVE DU SOJA [cite: 109]",
|
| 4 |
+
"description": "Ces Logigrammes ont été construits pour faciliter l'application du Guide de calcul de l'impact carbone des aliments composés[cite: 110]. Ils constituent un outil d'aide, construit par le GT Carbone réunissant des professionnels des syndicats[cite: 111].",
|
| 5 |
+
"regles_generales": [
|
| 6 |
+
"Le choix des valeurs précises de facteur d'émission des intrants relève de la compétence et de la responsabilité des entreprises[cite: 112, 113].",
|
| 7 |
+
"A ce titre, elles doivent être en mesure de justifier les choix effectués[cite: 114].",
|
| 8 |
+
"La bonne application du guide de calcul de l'impact carbone impose d'attribuer une valeur de facteur d'émission à tous les intrants[cite: 115].",
|
| 9 |
+
"Lorsque l'entreprise ne connait pas la provenance d'un intrant, il conviendra de retenir par défaut le facteur d'émission le plus défavorable de l'intrant en question[cite: 116].",
|
| 10 |
+
"Lorsque l'entreprise connait la provenance de l'intrant mais que la donnée précise n'existe pas, il conviendra de prendre la donnée générique disponible la plus pertinente[cite: 117].",
|
| 11 |
+
"Lorsque les données ne sont pas présentes dans la base de données GFLI ou ECOALIM, les opérateurs peuvent s'orienter vers d'autres bases reconnues (tel agribalyse par exemple) dans la mesure où ils sont en capacité de justifier le choix effectué[cite: 118]."
|
| 12 |
+
],
|
| 13 |
+
"point_entree": "Mon intrant est une matière première dérivée du soja [cite: 119, 120, 121]"
|
| 14 |
+
},
|
| 15 |
+
"flowchart": {
|
| 16 |
+
"start_node": "node_1",
|
| 17 |
+
"nodes": [
|
| 18 |
+
{
|
| 19 |
+
"id": "node_1",
|
| 20 |
+
"type": "question",
|
| 21 |
+
"text": "Connaissez-vous le pays d'origine de la graine à l'origine de l'intrant ?",
|
| 22 |
+
"options": [
|
| 23 |
+
{
|
| 24 |
+
"label": "Je connais le pays d'origine de la graine à l'origine de l'intrant [cite: 122]",
|
| 25 |
+
"next_node": "node_2"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"label": "Je ne connais pas le pays d'origine de la graine à l'origine de l'intrant \"soja ou produit dérivé du soja\" [cite: 136]",
|
| 29 |
+
"next_node": "node_3"
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"id": "node_2",
|
| 35 |
+
"type": "question",
|
| 36 |
+
"text": "Quel est le niveau de transformation de l'intrant ?",
|
| 37 |
+
"options": [
|
| 38 |
+
{
|
| 39 |
+
"label": "L'intrant est du soja non transformé (= des graines de soja crues) [cite: 123]",
|
| 40 |
+
"next_node": "node_4"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"label": "L'intrant est un produit dérivé du soja [cite: 124]",
|
| 44 |
+
"next_node": "node_5"
|
| 45 |
+
}
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"id": "node_3",
|
| 50 |
+
"type": "question",
|
| 51 |
+
"text": "Quel est le niveau de transformation de l'intrant ?",
|
| 52 |
+
"options": [
|
| 53 |
+
{
|
| 54 |
+
"label": "L'intrant est du soja non transformé (= des graines crues de soja) [cite: 137]",
|
| 55 |
+
"next_node": "node_6"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"label": "L'intrant est un produit dérivé du soja [cite: 138]",
|
| 59 |
+
"next_node": "node_7"
|
| 60 |
+
}
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "node_4",
|
| 65 |
+
"type": "resultat",
|
| 66 |
+
"actions_priorisees": [
|
| 67 |
+
"1. Je prends la valeur \"graines\" du pays correspondant dans ECOALIM[cite: 125].",
|
| 68 |
+
"2. Si la valeur n'existe pas, je prends la valeur \"graines\" du pays correspondant dans GFLI[cite: 126].",
|
| 69 |
+
"3. Si la valeur n'existe pas, je prends la valeur GFLI du Mix Européen (RER) pour des graines crues provenant d'Europe et la valeur GFLI du Mix Monde (GLO) pour des graines crues provenant d'un autre continent[cite: 133]."
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "node_5",
|
| 74 |
+
"type": "resultat",
|
| 75 |
+
"actions_priorisees": [
|
| 76 |
+
"1. Si le couple \"intrant\" \"origine de la graine\" existe dans ECOALIM, je prends une valeur dans ECOALIM[cite: 127]. S'il y a plusieurs lieux de transformation, je prends la valeur du lieu de transformation correspondant à ma situation[cite: 128]. Si le lieu de transformation n'existe pas ou si je ne connais pas le lieu de transformation de mon intrant, je prends la valeur la plus défavorable pour le couple \"intrant\" \"origine de la graine\"[cite: 129].",
|
| 77 |
+
"2. Si le couple \"intrant\" \"origine de la graine\" n'existe pas dans ECOALIM : 1/ Si je connais de manière fiable l'impact du process de transformation (sans génération de coproduits), je pars de la valeur pour les graines crues dans ECOALIM et j'ajoute l'impact du process[cite: 130, 131]. 2/ Si je ne connais pas de manière fiable l'impact du process de transformation, je prends dans le GFLI la valeur \"produit dérivé du soja\" pour le pays de transformation correspondant ou la valeur la plus défavorable si je ne connais pas le lieu de transformation[cite: 132].",
|
| 78 |
+
"3. Si cela n'est pas possible, je prends la valeur GFLI du Mix Européen (RER) si le soja transformé provient d'Europe ou la valeur GFLI du Mix Monde (GLO) si le soja transformé vient d'un autre continent[cite: 134].",
|
| 79 |
+
"4. En dernier recours, je prends la valeur pour l'intrant, intégrant le process le plus proche, disponible dans ECOALIM ou GFLI[cite: 135]."
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "node_6",
|
| 84 |
+
"type": "resultat",
|
| 85 |
+
"actions_priorisees": [
|
| 86 |
+
"1. Je prends la valeur \"graines\" la plus défavorable entre les 2 bases de données (GFLI/ECOALIM)[cite: 139]."
|
| 87 |
+
]
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"id": "node_7",
|
| 91 |
+
"type": "question",
|
| 92 |
+
"text": "Connaissez-vous le lieu de transformation ?",
|
| 93 |
+
"options": [
|
| 94 |
+
{
|
| 95 |
+
"label": "Oui, je connais le lieu de transformation [cite: 140]",
|
| 96 |
+
"next_node": "node_8"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"label": "Non, je ne connais pas le lieu de transformation [cite: 142]",
|
| 100 |
+
"next_node": "node_9"
|
| 101 |
+
}
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "node_8",
|
| 106 |
+
"type": "resultat",
|
| 107 |
+
"actions_priorisees": [
|
| 108 |
+
"1. Je prends la valeur correspondante au pays de transformation dans ECOALIM si elle existe[cite: 140]. S'il existe plusieurs valeurs pour ce pays de transformation, je prends la valeur la plus défavorable[cite: 141].",
|
| 109 |
+
"2. Si la valeur n'existe pas dans ECOALIM, je prends la valeur correspondante au pays de transformation dans le GFLI[cite: 143].",
|
| 110 |
+
"3. Si la valeur n'existe pas, je prends la valeur GFLI du Mix Européen (RER) pour le soja transformé provenant d'Europe et la valeur GFLI du Mix Monde (GLO) pour le soja transformé provenant d'un autre continent[cite: 145].",
|
| 111 |
+
"4. En dernier recours, je prends la valeur pour l'intrant, intégrant le process le plus proche, disponible dans ECOALIM ou GFLI[cite: 146]."
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"id": "node_9",
|
| 116 |
+
"type": "resultat",
|
| 117 |
+
"actions_priorisees": [
|
| 118 |
+
"1. Je prends la valeur la plus défavorable pour cet intrant entre les 2 bases de données (GFLI /ECOALIM)[cite: 142].",
|
| 119 |
+
"2. En dernier recours, je prends la valeur pour l'intrant, intégrant le process le plus proche, disponible dans ECOALIM ou GFLI[cite: 144]."
|
| 120 |
+
]
|
| 121 |
+
}
|
| 122 |
+
]
|
| 123 |
+
}
|
| 124 |
+
}
|
requirements.txt
CHANGED
|
@@ -4,4 +4,3 @@ pdfplumber>=0.11.0
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
openpyxl>=3.1.0
|
| 6 |
python-dotenv>=1.0.0
|
| 7 |
-
datasets>=4.5.0
|
|
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
openpyxl>=3.1.0
|
| 6 |
python-dotenv>=1.0.0
|
|
|