# app.py — Formulaire EAN/Libellé + logs (UI & Space) — style "IPC / Insee" import io, sys, json, re import gradio as gr from contextlib import redirect_stdout, redirect_stderr # 👉 imports utilitaires de ton agent (pas besoin de build_agent ici) from quick_deploy_agent import parse_result, run_task_with_fallback # ---------- util "tee" pour dupliquer les logs ---------- class _Tee(io.TextIOBase): def __init__(self, *streams): self.streams = streams def write(self, s): for st in self.streams: try: st.write(s) except Exception: pass return len(s) def flush(self): for st in self.streams: try: st.flush() except Exception: pass # ---------- helper: extraire un JSON valide d'un texte ---------- def _extract_json(text: str): """ Essaie de parser un JSON valable depuis une chaîne. - d'abord json.loads direct - sinon, extrait le contenu d'un bloc ```json ... ``` puis parse - renvoie None si rien de valable """ if isinstance(text, dict): return text if not isinstance(text, str): return None # 1) direct try: return json.loads(text) except Exception: pass # 2) dans des backticks try: m = re.search(r"```(?:json)?\s*(\{[\s\S]*\})\s*```", text) if m: return json.loads(m.group(1)) except Exception: pass return None # ---------- prompt interne (l'utilisateur ne tape rien) ---------- TASK_TMPL = """\ Classe ce produit en COICOP: EAN: {ean} Libellé: {label} Outils autorisés : - validate_ean - openfoodfacts_product_by_ean - map_off_to_coicop - coicop_regex_rules - coicop_semantic_similarity - merge_candidates - resolve_coicop_candidates - python_interpreter # UNIQUEMENT pour lignes simples d’assignation ou d’appel d’outil (voir ci-dessous) Règles STRICTES d’écriture de code : - Pas de structures de contrôle Python : aucun if, else, for, while, try, with, def, class. - Aucun print, aucun logging, aucune concaténation multi-ligne. - Chaque bloc de code contient une seule instruction Python, sur une seule ligne. - Commencer par définir deux variables : 1) EAN_STR = "{ean}" (chaîne simple) 2) LBL = \"\"\"{label}\"\"\" (triple guillemets pour éviter les erreurs de guillemets) - Pour tous les outils qui prennent le libellé, utiliser LBL (ne jamais insérer le libellé en littéral). - La fonction validate_ean renvoie un dictionnaire avec les clés 'valid' (booléen) et 'normalized' (chaîne). Ne pas la traiter comme un booléen directement. Règles STRICTES de sortie : - Terminer par un unique objet JSON valide en appelant final_answer avec cet objet (aucun texte hors JSON). - Ne pas utiliser de backticks. - Le JSON final doit contenir les clés : final, alternatives, candidates_top, explanation. Branchements (décision prise sans écrire de if en code) : - MODE AVEC EAN si EAN_STR n’est pas "N/A" et si validate_ean(EAN_STR) indique que 'valid' est vrai et si l’appel OpenFoodFacts réussit (champ ok vrai). - Sinon, MODE SANS EAN. Pipeline — MODE AVEC EAN : 1) v = validate_ean(EAN_STR) 2) off = openfoodfacts_product_by_ean(EAN_STR) 3) offmap = map_off_to_coicop(off_payload=off) 4) rx = coicop_regex_rules(text=LBL) 5) sem = coicop_semantic_similarity(text=LBL, topk=5) 6) merged = merge_candidates(candidates_lists=[offmap, rx, sem], min_k=3, fallback_bias="cheese") 7) res = resolve_coicop_candidates(json_lists=[merged], topn=3) → Appeler immédiatement final_answer avec res (objet JSON complet). Pipeline — MODE SANS EAN : 1) rx = coicop_regex_rules(text=LBL) 2) sem = coicop_semantic_similarity(text=LBL, topk=5) 3) merged = merge_candidates(candidates_lists=[rx, sem], min_k=3, fallback_bias="cheese") 4) res = resolve_coicop_candidates(json_lists=[merged], topn=3) → Appeler immédiatement final_answer avec res (objet JSON complet). Contraintes d’usage : - N’utiliser python_interpreter que pour des lignes uniques d’assignation ou d’appel d’outil (format : var = tool(args) ou tool(args)). - Ne créer aucun fichier et ne faire aucune entrée/sortie externe. """ def classify(label: str, ean: str): label = (label or "").strip() ean = (ean or "").strip() if not label and not ean: return json.dumps({"error": "Veuillez saisir un libellé ou un EAN."}, ensure_ascii=False, indent=2), "—" # Construire le message pour l’agent task = TASK_TMPL.format(ean=ean or "N/A", label=label or "N/A") # Buffers UI + duplication vers logs Space (stdout/stderr) buf_out, buf_err = io.StringIO(), io.StringIO() tee_out, tee_err = _Tee(sys.stdout, buf_out), _Tee(sys.stderr, buf_err) print("\n===== Agent run start =====") print(task) try: # Exécuter l’agent via la fonction tolérante aux timeouts et modèles de repli with redirect_stdout(tee_out), redirect_stderr(tee_err): res = run_task_with_fallback(task) # Parsing robuste du résultat obj = None if isinstance(res, dict): obj = res if obj is None: obj = _extract_json(res) if obj is None: try: obj = parse_result(res) # parseur du projet except Exception: obj = None if obj is None: obj = {"raw": str(res)} # dernier recours logs_ui = "\n".join( s for s in [buf_out.getvalue().strip(), buf_err.getvalue().strip()] if s ) or "(aucun log)" print("===== Agent run end =====\n") return json.dumps(obj, ensure_ascii=False, indent=2), logs_ui except Exception as e: logs_ui = "\n".join( s for s in [buf_out.getvalue().strip(), buf_err.getvalue().strip()] if s ) or "(aucun log)" print(f"✖ Agent error: {e}") return json.dumps({"error": str(e)}, ensure_ascii=False, indent=2), logs_ui def fill_example(): # EAN réel OFF (Les p'tits crémeux – Aldi – 216 g) return "Camembert au lait cru AOP 250g - ALDI", "2006050033638" # ---------- thème & CSS (style IPC / Insee) ---------- theme = gr.themes.Soft( primary_hue="blue", secondary_hue="indigo", neutral_hue="slate", ) custom_css = """ :root{ --insee-primary: #89c2d9; --insee-primary-700:#89c2d9; --insee-accent: #ff5c35; --insee-neutral-50:#f8fafc; --insee-neutral-100:#f1f5f9; --insee-neutral-900:#0f172a; --radius-lg: 16px; } .gradio-container{ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", sans-serif; background: linear-gradient(180deg, var(--insee-neutral-50), #ffffff 30%); } #app-header{ background: linear-gradient(90deg, var(--insee-primary), var(--insee-primary-700)); color: white; padding: 18px 22px; border-radius: 18px; box-shadow: 0 10px 30px rgba(11,61,145,0.25); margin-bottom: 16px; } #app-header h1{ margin:0; font-size: 22px; letter-spacing: .3px; } #app-header p{ margin:6px 0 0; opacity:.92 } .card{ background: #fff; border: 1px solid var(--insee-neutral-100); border-radius: var(--radius-lg); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); padding: 18px; } .gr-textbox textarea{ font-size: 16px; line-height: 1.35; padding: 14px 16px; border: 2px solid #e5e7eb; border-radius: 14px; } .gr-textbox textarea:focus{ outline: 3px solid color-mix(in oklab, var(--insee-primary) 22%, white); border-color: var(--insee-primary); box-shadow: 0 0 0 4px rgba(11,61,145,0.15); } .gr-button{ font-weight: 700; letter-spacing: .2px; border-radius: 14px; transition: transform .06s ease, box-shadow .06s ease; } .gr-button:hover{ transform: translateY(-1px); } .gr-button:focus{ outline: 3px solid color-mix(in oklab, var(--insee-primary) 30%, white); box-shadow: 0 0 0 4px rgba(11,61,145,.18); } /* primaire */ button.primary{ background: linear-gradient(180deg, var(--insee-primary), var(--insee-primary-700)); color: white; border: none; } /* bouton secondaire "fantôme" */ button.ghost{ background: white; color: var(--insee-primary); border: 2px solid var(--insee-primary); } button.ghost:hover{ background: color-mix(in oklab, var(--insee-primary) 6%, white); } .gr-code pre, .gr-code code{ font-size: 13.5px; line-height: 1.45; border-radius: 14px; } """ # ---------- UI ---------- with gr.Blocks(title="OpenFoodFactsAgent (COICOP)", theme=theme, css=custom_css) as demo: gr.HTML("""
Analyse de libellé ou d’EAN, interrogation Open Food Facts, règles & sémantique, fusion des candidats.