File size: 10,082 Bytes
e65e7d4
be4fdb8
e65e7d4
 
 
be4fdb8
 
e65e7d4
 
be4fdb8
 
 
 
 
 
 
 
 
 
 
 
 
e65e7d4
be4fdb8
e65e7d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259f12d
e65e7d4
 
 
 
 
 
 
 
c2b538a
 
 
929058f
 
 
 
 
 
 
 
259f12d
 
929058f
 
 
259f12d
929058f
 
c2b538a
259f12d
c2b538a
 
 
 
 
 
e65e7d4
 
9549278
259f12d
c2b538a
 
 
 
 
9549278
259f12d
 
929058f
 
e65e7d4
 
 
9549278
929058f
e65e7d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be4fdb8
e65e7d4
be4fdb8
e65e7d4
 
 
 
 
 
 
 
 
be4fdb8
e65e7d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be4fdb8
e65e7d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be4fdb8
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# 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)