Spaces:
Sleeping
Sleeping
| # 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(""" | |
| <div id="app-header"> | |
| <h1>🧮 Agent COICOP — Classification IPC</h1> | |
| <p>Analyse de libellé ou d’EAN, interrogation Open Food Facts, règles & sémantique, fusion des candidats.</p> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| with gr.Group(elem_classes=["card"]): | |
| gr.Markdown("### Saisie") | |
| label_in = gr.Textbox(label="Libellé de produit", | |
| placeholder="Ex. Camembert au lait cru AOP 250g - ALDI", | |
| lines=2) | |
| ean_in = gr.Textbox(label="EAN (optionnel)", | |
| placeholder="Ex. 2006050033638") | |
| with gr.Row(): | |
| btn_go = gr.Button("Classifier en COICOP", elem_classes=["primary"]) | |
| btn_ex = gr.Button("Exemple", elem_classes=["ghost"]) | |
| with gr.Column(scale=1): | |
| with gr.Group(elem_classes=["card"]): | |
| gr.Markdown("### Résultat JSON") | |
| out_json = gr.Code(language="json") | |
| with gr.Group(elem_classes=["card"]): | |
| gr.Markdown("### Logs de l’agent") | |
| out_logs = gr.Textbox(lines=16) | |
| btn_go.click(fn=classify, inputs=[label_in, ean_in], outputs=[out_json, out_logs]) | |
| btn_ex.click(fn=fill_example, outputs=[label_in, ean_in]) | |
| if __name__ == "__main__": | |
| # SSR est activé par défaut sur Spaces; ajuste si besoin: | |
| demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=True) | |