JosephMcDonnell commited on
Commit
a89d575
·
1 Parent(s): 3bafa5f
Files changed (5) hide show
  1. src/app.py +263 -66
  2. src/config.py +2 -2
  3. src/data_loader.py +45 -15
  4. src/flowchart_engine.py +477 -67
  5. src/llm_service.py +341 -7
src/app.py CHANGED
@@ -8,6 +8,7 @@ import streamlit as st
8
  import pandas as pd
9
 
10
  from flowchart_engine import evaluate_carbon_impact, CarbonResult
 
11
  import data_loader
12
  import config
13
 
@@ -41,12 +42,6 @@ def get_country_list():
41
  noms = sorted(set(k.title() for k in config.PAYS_FR_TO_ISO.keys()))
42
  return noms
43
 
44
- @st.cache_data
45
- def get_matiere_list():
46
- """Retourne la liste des matières premières disponibles dans EcoALIM."""
47
- return data_loader.get_ecoalim_matieres()
48
-
49
-
50
  # ============================================================================
51
  # Composant autocomplete maison
52
  # ============================================================================
@@ -94,12 +89,11 @@ col_form, col_info = st.columns([2, 1])
94
  with col_form:
95
  st.subheader("📝 Formulaire de saisie")
96
 
97
- matiere = autocomplete_input(
98
  "Nom de la matière première",
99
- get_matiere_list(),
100
  key="input_matiere",
101
  placeholder="Ex : BLE, T.TNSL DEC., ORGE, T. COLZA, LUZERNE…",
102
- help_text="Entrez le nom usuel de la matière première. Des suggestions apparaîtront.",
103
  )
104
 
105
  provenance_connue = st.radio(
@@ -163,8 +157,130 @@ if run_button:
163
  pays_transformation=pays_transfo,
164
  )
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  st.divider()
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  # ------------------------------------------------------------------
169
  # Section 1 : Résultat principal
170
  # ------------------------------------------------------------------
@@ -177,30 +293,17 @@ if run_button:
177
 
178
  with col1:
179
  if result.impact_kg_co2_eq is not None:
180
- if "tonne" in result.unite_source:
181
- # GFLI : valeur en kg CO2 / tonne
182
- st.metric(
183
- label="Impact carbone",
184
- value=f"{result.impact_kg_co2_eq:.2f}",
185
- delta=f"kg CO2 eq / tonne",
186
- )
187
- st.metric(
188
- label="Soit en tonnes CO2 eq / tonne produit",
189
- value=f"{result.impact_tonne_co2_eq:.4f}",
190
- delta="t CO2 eq / t produit",
191
- )
192
  else:
193
- # EcoALIM : valeur en kg CO2 / kg
194
- st.metric(
195
- label="Impact carbone",
196
- value=f"{result.impact_kg_co2_eq:.4f}",
197
- delta=f"kg CO2 eq / kg",
198
- )
199
- st.metric(
200
- label="Soit en tonnes CO2 eq / tonne produit",
201
- value=f"{result.impact_kg_co2_eq:.4f}",
202
- delta="t CO2 eq / t produit (même valeur numérique)",
203
- )
204
 
205
  with col2:
206
  st.markdown(f"**Source :** {result.source_db}")
@@ -215,6 +318,103 @@ if run_button:
215
  if result.pays_transformation:
216
  st.markdown(f"**Pays transformation :** {result.pays_transformation}")
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  # ------------------------------------------------------------------
219
  # Section 2 : Parcours de logique (logigramme)
220
  # ------------------------------------------------------------------
@@ -234,11 +434,28 @@ if run_button:
234
  # ------------------------------------------------------------------
235
  st.subheader("🔍 Détail des recherches effectuées")
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  for action in result.actions_appliquees:
238
- if action.startswith(" →"):
239
- st.success(action)
 
240
  else:
241
- st.markdown(f"- {action}")
242
 
243
  # ------------------------------------------------------------------
244
  # Section 4 : Justification si valeur alternative
@@ -248,35 +465,7 @@ if run_button:
248
  st.info(result.justification_alternative)
249
 
250
  # ------------------------------------------------------------------
251
- # Section 5 : Candidats alternatifs
252
- # ------------------------------------------------------------------
253
- if result.candidats_alternatifs:
254
- st.subheader("📋 Produits candidats (triés par pertinence)")
255
- if not result.match_exact:
256
- st.warning("⚠️ Pas de correspondance exacte — voici les produits les plus proches avec leur impact carbone.")
257
- else:
258
- st.info("ℹ️ Autres produits correspondant à la recherche dans les bases de données.")
259
-
260
- # Construire un DataFrame pour un affichage clair
261
- df_candidates = pd.DataFrame(result.candidats_alternatifs)
262
- df_candidates = df_candidates.rename(columns={
263
- "nom": "Intrant",
264
- "impact": "Impact carbone",
265
- "unite": "Unité",
266
- "source": "Base",
267
- })
268
- # Formater les colonnes
269
- st.dataframe(
270
- df_candidates,
271
- use_container_width=True,
272
- hide_index=True,
273
- column_config={
274
- "Impact carbone": st.column_config.NumberColumn(format="%.4f"),
275
- },
276
- )
277
-
278
- # ------------------------------------------------------------------
279
- # Section 6 : Classification détaillée
280
  # ------------------------------------------------------------------
281
  with st.expander("📋 Détail de la classification brut/transformé"):
282
  st.markdown(f"**Classification :** {result.classification}")
@@ -334,8 +523,16 @@ if uploaded_file is not None:
334
  "Pays production": pays_p or "",
335
  "Pays transformation": pays_t or "",
336
  "Classification": res.classification,
337
- "Impact (kg CO2 eq)": res.impact_kg_co2_eq,
338
- "Unité": res.unite_source,
 
 
 
 
 
 
 
 
339
  "Source": res.source_db,
340
  "Intrant utilisé": res.intrant_utilise,
341
  "Match exact": "Oui" if res.match_exact else "Non",
 
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
 
 
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
  # ============================================================================
 
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(
 
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
  # ------------------------------------------------------------------
 
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}")
 
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
  # ------------------------------------------------------------------
 
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
 
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}")
 
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",
src/config.py CHANGED
@@ -6,12 +6,11 @@ from dotenv import load_dotenv
6
 
7
  load_dotenv()
8
 
9
- IS_PRODUCTION = bool(os.getenv("IS_PRODUCTION", 0))
10
  # ---------------------------------------------------------------------------
11
  # Clé API Mistral
12
  # ---------------------------------------------------------------------------
13
  MISTRAL_API_KEY: str = os.getenv("MISTRAL_API_KEY", "")
14
-
15
  # Clé Hugging Face
16
  HF_KEY = os.getenv("HF_KEY", "")
17
  # ---------------------------------------------------------------------------
@@ -139,3 +138,4 @@ EUROPEAN_COUNTRIES_FR = {
139
 
140
  # Modèle Mistral à utiliser
141
  MISTRAL_MODEL = "mistral-small-latest"
 
 
6
 
7
  load_dotenv()
8
 
 
9
  # ---------------------------------------------------------------------------
10
  # Clé API Mistral
11
  # ---------------------------------------------------------------------------
12
  MISTRAL_API_KEY: str = os.getenv("MISTRAL_API_KEY", "")
13
+ IS_PRODUCTION = bool(os.getenv("IS_PRODUCTION", 0))
14
  # Clé Hugging Face
15
  HF_KEY = os.getenv("HF_KEY", "")
16
  # ---------------------------------------------------------------------------
 
138
 
139
  # Modèle Mistral à utiliser
140
  MISTRAL_MODEL = "mistral-small-latest"
141
+ MISTRAL_MODEL_POWERFUL = "mistral-large-latest" # Pour analyses complexes (alternatives, tri)
src/data_loader.py CHANGED
@@ -4,14 +4,13 @@ data_loader.py - Chargement et indexation des bases de données EcoALIM, GFLI et
4
  from __future__ import annotations
5
 
6
  import json
7
- import os
8
  import re
9
  from functools import lru_cache
10
  from typing import Dict, List, Optional, Tuple
 
11
 
12
  import pandas as pd
13
  import pdfplumber
14
- from datasets import load_dataset, DownloadMode
15
 
16
  import config
17
 
@@ -54,6 +53,18 @@ def _normalize_for_search(text: str) -> str:
54
  return ascii_text
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def is_name_match(matiere: str, intrant_name: str) -> bool:
58
  """
59
  Vérifie si le nom de la matière est une correspondance réelle (mot entier)
@@ -95,6 +106,12 @@ def search_ecoalim(
95
  mask_starts = df_norms.str.startswith(matiere_norm, na=False)
96
  pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
97
  mask_word = df_norms.str.contains(pattern_word, na=False, regex=True)
 
 
 
 
 
 
98
  mask_contains = df_norms.str.contains(re.escape(matiere_norm), na=False)
99
 
100
  # Use best available mask with priority
@@ -102,6 +119,8 @@ def search_ecoalim(
102
  mask = mask_starts
103
  elif mask_word.any():
104
  mask = mask_word
 
 
105
  elif mask_contains.any():
106
  mask = mask_contains
107
  else:
@@ -130,10 +149,14 @@ def search_ecoalim(
130
  # Sort by relevance: entries starting with the search term come first
131
  if not result.empty:
132
  result_norms = result[nom_col].apply(lambda x: _normalize_for_search(str(x)))
133
- result["_priority"] = 2
134
  result.loc[result_norms.str.contains(pattern_word, na=False, regex=True), "_priority"] = 1
135
  result.loc[result_norms.str.startswith(matiere_norm, na=False), "_priority"] = 0
136
- result = result.sort_values("_priority").drop(columns=["_priority"])
 
 
 
 
137
 
138
  return result
139
 
@@ -221,15 +244,21 @@ def search_gfli(
221
  prod_col = config.GFLI_COL_PRODUCT
222
  df_norms = df[prod_col].apply(lambda x: _normalize_for_search(str(x)) if pd.notna(x) else "")
223
 
224
- # Strategy 1: word-boundary match
225
- pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
226
- mask = df_norms.str.contains(pattern_word, na=False, regex=True)
 
 
 
 
227
 
228
- # Strategy 2: starts-with
229
  if not mask.any():
230
- mask = df_norms.str.startswith(matiere_norm, na=False)
 
 
231
 
232
- # Strategy 3: contains
233
  if not mask.any():
234
  mask = df_norms.str.contains(re.escape(matiere_norm), na=False)
235
 
@@ -316,7 +345,7 @@ def get_top_ecoalim_candidates(
316
  matiere: str,
317
  pays_production: Optional[str] = None,
318
  pays_transformation: Optional[str] = None,
319
- top_n: int = 8,
320
  ) -> List[Dict]:
321
  """
322
  Retourne les top N correspondances EcoALIM triées par pertinence,
@@ -326,7 +355,8 @@ def get_top_ecoalim_candidates(
326
  if results.empty:
327
  return []
328
  candidates = []
329
- for _, row in results.head(top_n).iterrows():
 
330
  val = row.get(config.ECOALIM_COL_CLIMATE)
331
  if pd.notna(val):
332
  candidates.append({
@@ -341,7 +371,7 @@ def get_top_ecoalim_candidates(
341
  def get_top_gfli_candidates(
342
  matiere: str,
343
  country_iso: Optional[str] = None,
344
- top_n: int = 8,
345
  ) -> List[Dict]:
346
  """
347
  Retourne les top N correspondances GFLI triées par pertinence,
@@ -351,7 +381,8 @@ def get_top_gfli_candidates(
351
  if results.empty:
352
  return []
353
  candidates = []
354
- for _, row in results.head(top_n).iterrows():
 
355
  val = row.get(config.GFLI_COL_CLIMATE)
356
  if pd.notna(val):
357
  candidates.append({
@@ -392,7 +423,6 @@ def load_pdf_text() -> str:
392
  def get_pdf_excerpt(max_chars: int = 15000) -> str:
393
  """Retourne un extrait du PDF CIR (tronqué si nécessaire) pour envoi au LLM."""
394
  text = load_pdf_text()
395
-
396
  if len(text) > max_chars:
397
  return text[:max_chars] + "\n... [texte tronqué]"
398
  return text
 
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
+ from datasets import load_dataset,DownloadMode
11
 
12
  import pandas as pd
13
  import pdfplumber
 
14
 
15
  import config
16
 
 
53
  return ascii_text
54
 
55
 
56
+ _STOPWORDS_FR = {
57
+ "de", "du", "des", "la", "le", "les", "d", "l", "a", "au", "aux"
58
+ }
59
+
60
+
61
+ def _tokens_for_search(text: str) -> list[str]:
62
+ """Découpe un texte en tokens utiles pour une recherche souple."""
63
+ text = _normalize_for_search(text)
64
+ tokens = re.findall(r"[a-z0-9]+", text)
65
+ return [t for t in tokens if t and t not in _STOPWORDS_FR]
66
+
67
+
68
  def is_name_match(matiere: str, intrant_name: str) -> bool:
69
  """
70
  Vérifie si le nom de la matière est une correspondance réelle (mot entier)
 
106
  mask_starts = df_norms.str.startswith(matiere_norm, na=False)
107
  pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
108
  mask_word = df_norms.str.contains(pattern_word, na=False, regex=True)
109
+ tokens = _tokens_for_search(matiere_norm)
110
+ mask_tokens = pd.Series(False, index=df.index)
111
+ if tokens:
112
+ mask_tokens = df_norms.apply(
113
+ lambda x: all(t in _tokens_for_search(x) for t in tokens)
114
+ )
115
  mask_contains = df_norms.str.contains(re.escape(matiere_norm), na=False)
116
 
117
  # Use best available mask with priority
 
119
  mask = mask_starts
120
  elif mask_word.any():
121
  mask = mask_word
122
+ elif mask_tokens.any():
123
+ mask = mask_tokens
124
  elif mask_contains.any():
125
  mask = mask_contains
126
  else:
 
149
  # Sort by relevance: entries starting with the search term come first
150
  if not result.empty:
151
  result_norms = result[nom_col].apply(lambda x: _normalize_for_search(str(x)))
152
+ result["_priority"] = 3
153
  result.loc[result_norms.str.contains(pattern_word, na=False, regex=True), "_priority"] = 1
154
  result.loc[result_norms.str.startswith(matiere_norm, na=False), "_priority"] = 0
155
+ result.loc[result_norms.apply(lambda x: all(t in _tokens_for_search(x) for t in tokens)), "_priority"] = 2
156
+ # Prefer OS outputs over champ when ties exist
157
+ result["_os_priority"] = 1
158
+ result.loc[result_norms.str.contains("sortie os", na=False), "_os_priority"] = 0
159
+ result = result.sort_values(["_priority", "_os_priority"]).drop(columns=["_priority", "_os_priority"])
160
 
161
  return result
162
 
 
244
  prod_col = config.GFLI_COL_PRODUCT
245
  df_norms = df[prod_col].apply(lambda x: _normalize_for_search(str(x)) if pd.notna(x) else "")
246
 
247
+ # Strategy 1: starts-with
248
+ mask = df_norms.str.startswith(matiere_norm, na=False)
249
+
250
+ # Strategy 2: word-boundary match
251
+ if not mask.any():
252
+ pattern_word = r'\b' + re.escape(matiere_norm) + r'\b'
253
+ mask = df_norms.str.contains(pattern_word, na=False, regex=True)
254
 
255
+ # Strategy 3: token-subset match (souple)
256
  if not mask.any():
257
+ tokens = _tokens_for_search(matiere_norm)
258
+ if tokens:
259
+ mask = df_norms.apply(lambda x: all(t in _tokens_for_search(x) for t in tokens))
260
 
261
+ # Strategy 4: contains
262
  if not mask.any():
263
  mask = df_norms.str.contains(re.escape(matiere_norm), na=False)
264
 
 
345
  matiere: str,
346
  pays_production: Optional[str] = None,
347
  pays_transformation: Optional[str] = None,
348
+ top_n: Optional[int] = 8,
349
  ) -> List[Dict]:
350
  """
351
  Retourne les top N correspondances EcoALIM triées par pertinence,
 
355
  if results.empty:
356
  return []
357
  candidates = []
358
+ rows = results if top_n is None else results.head(top_n)
359
+ for _, row in rows.iterrows():
360
  val = row.get(config.ECOALIM_COL_CLIMATE)
361
  if pd.notna(val):
362
  candidates.append({
 
371
  def get_top_gfli_candidates(
372
  matiere: str,
373
  country_iso: Optional[str] = None,
374
+ top_n: Optional[int] = 8,
375
  ) -> List[Dict]:
376
  """
377
  Retourne les top N correspondances GFLI triées par pertinence,
 
381
  if results.empty:
382
  return []
383
  candidates = []
384
+ rows = results if top_n is None else results.head(top_n)
385
+ for _, row in rows.iterrows():
386
  val = row.get(config.GFLI_COL_CLIMATE)
387
  if pd.notna(val):
388
  candidates.append({
 
423
  def get_pdf_excerpt(max_chars: int = 15000) -> str:
424
  """Retourne un extrait du PDF CIR (tronqué si nécessaire) pour envoi au LLM."""
425
  text = load_pdf_text()
 
426
  if len(text) > max_chars:
427
  return text[:max_chars] + "\n... [texte tronqué]"
428
  return text
src/flowchart_engine.py CHANGED
@@ -51,6 +51,14 @@ class CarbonResult:
51
 
52
  # Candidats alternatifs (pour affichage comparatif quand match non exact)
53
  candidats_alternatifs: List[dict] = field(default_factory=list)
 
 
 
 
 
 
 
 
54
 
55
  erreur: Optional[str] = None
56
 
@@ -172,7 +180,7 @@ def _resolve_node_4(matiere: str, result: CarbonResult) -> CarbonResult:
172
  if eco_worst:
173
  val, nom, src = eco_worst
174
  result.impact_kg_co2_eq = val
175
- result.impact_tonne_co2_eq = val / 1000.0
176
  result.unite_source = "kg CO2 eq / kg de produit"
177
  result.source_db = src
178
  result.intrant_utilise = nom
@@ -200,6 +208,56 @@ def _resolve_node_4(matiere: str, result: CarbonResult) -> CarbonResult:
200
  result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']} = {val:.2f} kg CO2 eq/t")
201
  return result
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' dans GFLI ni ECOALIM."
204
  return result
205
 
@@ -278,7 +336,7 @@ def _resolve_node_5(matiere: str, result: CarbonResult) -> CarbonResult:
278
  if eco_worst:
279
  val, nom, src = eco_worst
280
  result.impact_kg_co2_eq = val
281
- result.impact_tonne_co2_eq = val / 1000.0
282
  result.unite_source = "kg CO2 eq / kg de produit"
283
  result.source_db = src
284
  result.intrant_utilise = nom
@@ -306,6 +364,56 @@ def _resolve_node_5(matiere: str, result: CarbonResult) -> CarbonResult:
306
  result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
307
  return result
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé, provenance inconnue)."
310
  return result
311
 
@@ -322,7 +430,7 @@ def _resolve_node_8(matiere: str, result: CarbonResult) -> CarbonResult:
322
  if eco_result:
323
  val = eco_result["valeur_kg_co2_eq"]
324
  result.impact_kg_co2_eq = val
325
- result.impact_tonne_co2_eq = val / 1000.0
326
  result.unite_source = "kg CO2 eq / kg de produit"
327
  result.source_db = eco_result["source"]
328
  result.intrant_utilise = eco_result["nom_intrant"]
@@ -350,7 +458,7 @@ def _resolve_node_8(matiere: str, result: CarbonResult) -> CarbonResult:
350
  if eco_smart:
351
  val = eco_smart["valeur_kg_co2_eq"]
352
  result.impact_kg_co2_eq = val
353
- result.impact_tonne_co2_eq = val / 1000.0
354
  result.unite_source = "kg CO2 eq / kg de produit"
355
  result.source_db = eco_smart["source"]
356
  result.intrant_utilise = eco_smart["nom_intrant"]
@@ -359,6 +467,56 @@ def _resolve_node_8(matiere: str, result: CarbonResult) -> CarbonResult:
359
  result.actions_appliquees.append(f" → Via LLM : {eco_smart['nom_intrant']}")
360
  return result
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (brut, France)."
363
  return result
364
 
@@ -422,7 +580,7 @@ def _resolve_node_9(matiere: str, pays_production: str, result: CarbonResult) ->
422
  if eco_result:
423
  val = eco_result["valeur_kg_co2_eq"]
424
  result.impact_kg_co2_eq = val
425
- result.impact_tonne_co2_eq = val / 1000.0
426
  result.unite_source = "kg CO2 eq / kg de produit"
427
  result.source_db = eco_result["source"]
428
  result.intrant_utilise = eco_result["nom_intrant"]
@@ -431,6 +589,56 @@ def _resolve_node_9(matiere: str, pays_production: str, result: CarbonResult) ->
431
  result.actions_appliquees.append(f" → Trouvé dans ECOALIM : {eco_result['nom_intrant']}")
432
  return result
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (brut, {pays_production})."
435
  return result
436
 
@@ -447,7 +655,7 @@ def _resolve_node_10(matiere: str, result: CarbonResult) -> CarbonResult:
447
  if eco_result:
448
  val = eco_result["valeur_kg_co2_eq"]
449
  result.impact_kg_co2_eq = val
450
- result.impact_tonne_co2_eq = val / 1000.0
451
  result.unite_source = "kg CO2 eq / kg de produit"
452
  result.source_db = eco_result["source"]
453
  result.intrant_utilise = eco_result["nom_intrant"]
@@ -477,7 +685,7 @@ def _resolve_node_10(matiere: str, result: CarbonResult) -> CarbonResult:
477
  if eco_smart:
478
  val = eco_smart["valeur_kg_co2_eq"]
479
  result.impact_kg_co2_eq = val
480
- result.impact_tonne_co2_eq = val / 1000.0
481
  result.unite_source = "kg CO2 eq / kg de produit"
482
  result.source_db = eco_smart["source"]
483
  result.intrant_utilise = eco_smart["nom_intrant"]
@@ -486,6 +694,56 @@ def _resolve_node_10(matiere: str, result: CarbonResult) -> CarbonResult:
486
  result.actions_appliquees.append(f" → Via LLM : {eco_smart['nom_intrant']}")
487
  return result
488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé, France/France)."
490
  return result
491
 
@@ -557,6 +815,56 @@ def _resolve_node_11(matiere: str, result: CarbonResult) -> CarbonResult:
557
  result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
558
  return result
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé France, MP brute hors FR)."
561
  return result
562
 
@@ -637,6 +945,56 @@ def _resolve_node_12(matiere: str, pays_transformation: str, result: CarbonResul
637
  result.actions_appliquees.append(f" → Via LLM : {gfli_smart['nom_intrant']}")
638
  return result
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  result.erreur = f"Aucune valeur trouvée pour '{matiere}' (transformé hors France)."
641
  return result
642
 
@@ -704,7 +1062,7 @@ def evaluate_carbon_impact(
704
  answer="Intrant brut/non transformé",
705
  ))
706
  result.node_resultat = "node_4"
707
- return _resolve_node_4(matiere_premiere, result)
708
  else:
709
  result.parcours.append(StepLog(
710
  node_id="node_2",
@@ -712,80 +1070,103 @@ def evaluate_carbon_impact(
712
  answer="Coproduit/intrant transformé",
713
  ))
714
  result.node_resultat = "node_5"
715
- return _resolve_node_5(matiere_premiere, result)
716
-
717
- # Provenance connue
718
- result.parcours.append(StepLog(
719
- node_id="node_1",
720
- question="Connaissez-vous l'endroit où l'intrant a été cultivé ou produit ?",
721
- answer=f"Oui — Production: {pays_production}" + (f", Transformation: {pays_transformation}" if pays_transformation else ""),
722
- ))
723
 
724
- if not is_transformed:
725
- # Node 3 → Node 6 : où a-t-il été cultivé ?
726
  result.parcours.append(StepLog(
727
- node_id="node_3",
728
- question="Quel est le niveau de transformation ?",
729
- answer="Intrant brut/non transformé",
730
  ))
731
 
732
- if _is_france(pays_production):
733
- result.parcours.append(StepLog(
734
- node_id="node_6",
735
- question="Où l'intrant brut a-t-il été cultivé ?",
736
- answer="En France",
737
- ))
738
- result.node_resultat = "node_8"
739
- return _resolve_node_8(matiere_premiere, result)
740
- else:
741
  result.parcours.append(StepLog(
742
- node_id="node_6",
743
- question=" l'intrant brut a-t-il été cultivé ?",
744
- answer=f"Hors France — {pays_production}",
745
  ))
746
- result.node_resultat = "node_9"
747
- return _resolve_node_9(matiere_premiere, pays_production, result)
748
 
749
- else:
750
- # Node 3 → Node 7 : où transformé + origine MP brute ?
751
- result.parcours.append(StepLog(
752
- node_id="node_3",
753
- question="Quel est le niveau de transformation ?",
754
- answer="Coproduit/intrant transformé",
755
- ))
 
 
 
 
 
 
 
 
 
756
 
757
- if _is_france(pays_transformation) and _is_france(pays_production):
 
758
  result.parcours.append(StepLog(
759
- node_id="node_7",
760
- question=" l'intrant a-t-il été transformé et d'où provient la MP brute ?",
761
- answer="Transformé en France à partir de MP brute française",
762
  ))
763
- result.node_resultat = "node_10"
764
- return _resolve_node_10(matiere_premiere, result)
765
 
766
- elif _is_france(pays_transformation):
767
- result.parcours.append(StepLog(
768
- node_id="node_7",
769
- question="Où l'intrant a-t-il été transformé et d'où provient la MP brute ?",
770
- answer=f"Transformé en France, MP brute de {pays_production or 'origine inconnue'}",
771
- ))
772
- result.node_resultat = "node_11"
773
- return _resolve_node_11(matiere_premiere, result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
 
 
 
 
 
 
 
 
775
  else:
776
- result.parcours.append(StepLog(
777
- node_id="node_7",
778
- question="Où l'intrant a-t-il été transformé et d'où provient la MP brute ?",
779
- answer=f"Transformé hors France — {pays_transformation}",
780
- ))
781
- result.node_resultat = "node_12"
782
- result = _resolve_node_12(matiere_premiere, pays_transformation or pays_production or "", result)
783
 
784
  # ------------------------------------------------------------------
785
  # Post-processing : collecter les candidats alternatifs
786
  # ------------------------------------------------------------------
787
  result = _collect_candidates(result)
788
 
 
 
 
 
 
 
 
 
 
 
 
789
  # Générer une justification LLM si le match n'est pas exact et qu'il n'y en a pas
790
  if not result.match_exact and not result.justification_alternative and not result.erreur:
791
  if result.intrant_utilise and result.impact_kg_co2_eq is not None:
@@ -827,29 +1208,54 @@ def _collect_candidates(result: CarbonResult) -> CarbonResult:
827
 
828
  # Collecter depuis la source utilisée + l'autre source
829
  # D'abord la source principalement utilisée
 
 
 
830
  if "ECOALIM" in source.upper():
831
  candidates.extend(data_loader.get_top_ecoalim_candidates(
832
  matiere,
833
  pays_production=result.pays_production,
834
  pays_transformation=result.pays_transformation,
835
- top_n=8,
836
  ))
 
 
 
 
 
 
 
837
  candidates.extend(data_loader.get_top_gfli_candidates(
838
- matiere, country_iso=country_iso, top_n=4,
839
  ))
 
 
 
 
840
  else:
841
  # Essayer aussi avec le nom traduit si on est sur GFLI
842
  # Le nom d'intrant utilisé contient le terme anglais
843
  intrant_base = result.intrant_utilise.split(",")[0].split("/")[0].strip()
844
  candidates.extend(data_loader.get_top_gfli_candidates(
845
- intrant_base, country_iso=country_iso, top_n=8,
846
  ))
 
 
 
 
847
  candidates.extend(data_loader.get_top_ecoalim_candidates(
848
  matiere,
849
  pays_production=result.pays_production,
850
  pays_transformation=result.pays_transformation,
851
- top_n=4,
852
  ))
 
 
 
 
 
 
 
853
 
854
  # Dédupliquer, exclure l'intrant sélectionné, et filtrer les faux positifs
855
  seen = set()
@@ -867,6 +1273,10 @@ def _collect_candidates(result: CarbonResult) -> CarbonResult:
867
  # Accepter quand même si ça matche le nom de base de l'intrant validé
868
  if intrant_base and _is_name_match(intrant_base, c["nom"]):
869
  pass # OK, même famille de produit
 
 
 
 
870
  else:
871
  continue # Faux positif
872
  seen.add(key)
 
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
 
 
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
 
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
 
 
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
 
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
 
 
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"]
 
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"]
 
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
 
 
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"]
 
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
 
 
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"]
 
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"]
 
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
 
 
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
 
 
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
 
 
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",
 
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 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:
 
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()
 
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)
src/llm_service.py CHANGED
@@ -33,6 +33,20 @@ def _chat(system_prompt: str, user_prompt: str) -> str:
33
  return response.choices[0].message.content.strip()
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  # ============================================================================
37
  # 1. Déterminer si une matière est brute ou transformée
38
  # ============================================================================
@@ -147,6 +161,42 @@ Réponds UNIQUEMENT avec la traduction anglaise, rien d'autre."""
147
  return matiere_name
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def _prefilter_gfli_names(matiere: str, available_names: list) -> list:
151
  """Pré-filtre les noms GFLI par mots-clés pour réduire la liste envoyée au LLM."""
152
  # Correspondances FR -> EN pour pré-filtrage
@@ -303,6 +353,23 @@ def smart_search_ecoalim(
303
  }
304
  # Faux positif — on continue vers le LLM
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  # Tentative via LLM
307
  match_info = find_matching_name_in_db(matiere, "ECOALIM")
308
  if match_info.get("matched_name") and match_info["matched_name"] != "AUCUN":
@@ -353,13 +420,32 @@ def smart_search_gfli(
353
  result = data_loader.get_gfli_climate_value(matiere_en, country_iso)
354
  if result:
355
  val, nom, source = result
356
- return {
357
- "valeur_kg_co2_eq_par_tonne": val,
358
- "nom_intrant": nom,
359
- "source": source,
360
- "match_exact": True,
361
- "justification": f"Traduction automatique : '{matiere}' → '{matiere_en}'",
362
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
  # Tentative via LLM
365
  match_info = find_matching_name_in_db(matiere, "GFLI")
@@ -381,3 +467,251 @@ def smart_search_gfli(
381
  "llm_match_info": match_info,
382
  }
383
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  # ============================================================================
 
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
 
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":
 
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")
 
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