Pierre RAFFALLI commited on
Commit
0946dfa
·
1 Parent(s): 3bafa5f

new features

Browse files
Files changed (9) hide show
  1. app.py +572 -0
  2. config.py +139 -0
  3. data_loader.py +415 -0
  4. flowchart_engine.py +1286 -0
  5. llm_service.py +717 -0
  6. logigramme.json +163 -0
  7. logigramme_mineral.json +93 -0
  8. logigramme_soja.json +124 -0
  9. 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