"""GLiNER2.5-multi-Decide demo — multilingual schema-driven classification on ZeroGPU. One 287M multilingual checkpoint answers any label set you pass at call time: intent, routing, sentiment, priority, policy, multi-label tags. No prompt template, no generated tokens, one forward pass per call. """ import spaces # MUST come before torch / any CUDA-touching import import json import gradio as gr from gliner2 import AutoExtractor MODEL_ID = "fastino/GLiNER2.5-multi-Decide" CACHE_VERSION = 2 # bump when example rows change, so cached outputs regenerate model = AutoExtractor.from_pretrained(MODEL_ID, map_location="cuda") model.eval() CSS = """ #col-container { max-width: 1100px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ HEADERS = ["Decision", "Labels (comma-separated)", "Multi-label", "Threshold"] DEFAULT_ROWS = [ ["sentiment", "positive, negative, mixed, neutral", False, 0.5], ] OVERRIDES_HELP = """ Optional JSON merged into a decision **by name**. Use it for a question prompt, label descriptions, or any other per-head config: ```json {"answer": {"prompt": "Did the treaty enter into force in 1992?"}} ``` """ def _to_rows(value) -> list: """Normalise a gr.Dataframe value (array, DataFrame, or dict) into rows.""" if value is None: return [] if hasattr(value, "values"): # pandas.DataFrame return value.values.tolist() if isinstance(value, dict): # {"headers": ..., "data": ...} return value.get("data") or [] return list(value) def _as_float(value, fallback: float) -> float: """Best-effort float coercion for a table cell.""" try: if value is None or value == "": return fallback return float(value) except (TypeError, ValueError): return fallback def _as_bool(value) -> bool: """Best-effort bool coercion for a table cell.""" if isinstance(value, str): return value.strip().lower() in {"true", "1", "yes", "multi", "on"} if not isinstance(value, bool): return False return value def build_tasks(rows, overrides_json: str) -> dict: """Turn the decisions table (plus optional JSON overrides) into a tasks dict. Args: rows: Table rows of ``[decision, labels, multi_label, threshold]``. overrides_json: JSON object merged into each named decision. Returns: ``{"tasks": {...}, "errors": [...]}`` where ``tasks`` is the mapping accepted by ``classify_text``. """ tasks: dict = {} errors: list = [] for row in _to_rows(rows): cells = list(row) + [None] * (4 - len(row)) name = str(cells[0] or "").strip() raw_labels = cells[1] if isinstance(raw_labels, (list, tuple)): labels = [str(l).strip() for l in raw_labels if str(l).strip()] else: labels = [l.strip() for l in str(raw_labels or "").split(",") if l.strip()] if not name and not labels: continue if not name: errors.append("Every row needs a decision name.") continue if len(labels) < 2: errors.append(f"“{name}” needs at least 2 labels.") continue if name in tasks: errors.append(f"“{name}” appears twice — decision names must be unique.") continue tasks[name] = { "labels": labels, "multi_label": _as_bool(cells[2]), "cls_threshold": _as_float(cells[3], 0.5), } raw_overrides = (overrides_json or "").strip() if raw_overrides and raw_overrides != "{}": try: extra = json.loads(raw_overrides) if not isinstance(extra, dict): raise ValueError("must be a JSON object") except (json.JSONDecodeError, ValueError) as exc: errors.append(f"Overrides JSON is invalid: {exc}") else: for name, cfg in extra.items(): if not isinstance(cfg, dict): errors.append(f"Override for “{name}” must be an object.") continue task = tasks.setdefault(name, {}) task.update(cfg) if not task.get("labels"): errors.append(f"“{name}” has no usable labels.") return {"tasks": tasks, "errors": errors} def summarize(result: dict, include_confidence: bool) -> str: """Render the classification payload as one line per decision head.""" if not result: return "_No decision was returned._" lines = [] for name, value in result.items(): if isinstance(value, list): parts = [] for item in value: label = item.get("label") if isinstance(item, dict) else item conf = item.get("confidence") if isinstance(item, dict) else None if include_confidence and conf is not None: parts.append(f"`{label}` ({conf:.2f})") else: parts.append(f"`{label}`") rendered = ", ".join(parts) if parts else "_(none)_" elif isinstance(value, dict): rendered = f"`{value.get('label')}`" if include_confidence and value.get("confidence") is not None: rendered = f"{rendered} ({value['confidence']:.2f})" else: rendered = f"`{value}`" lines.append(f"- **{name}** → {rendered}") return "\n".join(lines) @spaces.GPU(duration=30) def decide( text: str, rows: list, overrides_json: str = "{}", include_confidence: bool = False, ) -> tuple: """Classify text against your own label set with GLiNER2.5-multi-Decide. Works across languages: the model is a 287M multilingual encoder (mDeBERTa-v3-base) that scores every decision head in one forward pass. Args: text: The text to decide about, in any language. rows: Decisions table — one row per decision head: decision name, its comma-separated labels, whether it is multi-label, and the confidence threshold. All rows are scored in a single call. overrides_json: Optional JSON object merged into a decision by name, for a question prompt, label descriptions, or extra config. include_confidence: When true, keep the confidence score of each label. Returns: The raw classification payload as JSON, and a short readable summary. """ if not isinstance(include_confidence, bool): include_confidence = False if not (text or "").strip(): return "{}", "_Enter some text to classify._" built = build_tasks(rows, overrides_json) tasks, errors = built["tasks"], built["errors"] if not tasks: detail = " ".join(errors) or "Add at least one decision with two or more labels." return "{}", f"_{detail}_" result = model.classify_text(text, tasks, include_confidence=include_confidence) payload = json.dumps(result, indent=2, ensure_ascii=False, default=str) summary = summarize(result, include_confidence) if errors: summary = "⚠️ " + " ".join(errors) + "\n\n" + summary return payload, summary with gr.Blocks(title="GLiNER2.5-multi-Decide") as demo: gr.Markdown( """ # 🌍 GLiNER2.5-multi-Decide One 287M **multilingual** checkpoint, **any label set you pass at call time** — intent, routing, sentiment, priority, moderation, multi-label tags — in one forward pass. No prompt template, no generated tokens. Type the text in any language, then edit the **decisions table**: each row is one decision head, and every row is scored together in the same call. A single-label head returns one label; a multi-label head returns every label above its threshold. """ ) with gr.Row(): with gr.Column(scale=3): text_in = gr.Textbox( label="Text", placeholder="Paste the message, ticket, review or document to decide about — any language…", lines=6, ) decisions = gr.Dataframe( headers=HEADERS, datatype=["str", "str", "bool", "number"], type="array", column_count=4, row_count=4, value=DEFAULT_ROWS, label="Decisions", wrap=True, ) with gr.Accordion("Advanced options", open=False): gr.Markdown(OVERRIDES_HELP) overrides = gr.Code( label="Per-decision overrides (JSON, optional)", language="json", value="{}", lines=4, ) include_conf = gr.Checkbox( label="Include confidence scores", value=False, ) run = gr.Button("Decide", variant="primary") with gr.Column(scale=2): summary_out = gr.Markdown(label="Decisions") json_out = gr.Code(label="Raw output (JSON)", language="json", lines=14) run.click( decide, inputs=[text_in, decisions, overrides, include_conf], outputs=[json_out, summary_out], api_name="decide", ) gr.Examples( examples=[ [ "My subscription renewed on April 15 for ¥5,400 after the service was already down. Can I get that charge refunded?", [["intent", "order_status, refund_request, cancel_subscription, update_payment, login_problem, shipping_delay, bug_report, speak_to_human, other", False, 0.5]], "{}", ], [ "Bonjour, mon abonnement a été renouvelé le 15 avril pour 5 400 ¥ alors que le service était en panne. Puis-je être remboursé de ce prélèvement ?", [["intention", "statut_commande, demande_remboursement, annulation_abonnement, maj_paiement, probleme_connexion, retard_livraison, rapport_bug, parler_humain, autre", False, 0.5]], "{}", ], [ "El turno de mañana lo cubre Marta. Necesito cambiar mi vuelo del viernes a París al sábado por la mañana, la misma cabina.", [["peticion", "reservar, cambiar, cancelar, estado, cambio_asiento, reembolso, equipaje", False, 0.5]], "{}", ], [ "Battery dies before lunch, but the keyboard and the screen are the best I have used on a laptop.", [ ["sentiment", "positive, negative, mixed, neutral", False, 0.5], ["aspects", "battery, keyboard, screen, camera, price, support", True, 0.4], ], "{}", ], [ "バッテリーは昼前になくなりますが、キーボードと画面はこれまで使ったノートパソコンの中で最高です。", [["sentiment", "positive, negative, mixed, neutral", False, 0.5]], "{}", ], [ "From: compliance@group.example\nSubject: Protocol update — action required today\n\nPlease confirm the new retention rule is applied before Friday's audit.", [ ["intent", "fyi, request, approval, complaint, newsletter, security_alert", False, 0.5], ["urgency", "low, normal, high, critical", False, 0.5], ["route", "support, billing, legal, security, finance, archive", False, 0.5], ], "{}", ], [ "Guest in room 1408 says the AC has been out since yesterday and they want to move tonight or leave. They also asked for the incidentals hold to be released.", [ ["intent", "maintenance, room_change, checkout, billing, complaint, amenity_request", False, 0.5], ["priority", "low, normal, high, urgent", False, 0.5], ["needs_human", "yes, no", False, 0.5], ["topics", "hvac, billing, housekeeping, noise, safety", True, 0.4], ], "{}", ], [ "This is the third time I have explained the same missing refund. Stop the bot and get me a person.", [["handoff", "yes, no", False, 0.5]], "{}", ], [ "Payroll file has to be corrected before the 5pm cutoff or the whole company is paid late.", [["urgency", "0, 1, 2, 3, 4, 5", False, 0.5]], "{}", ], [ "The treaty was signed in Paris in 1992. It entered into force the following year, after the last signatory ratified it.", [["answer", "yes, no", False, 0.5]], '{"answer": {"prompt": "Did the treaty enter into force in 1992?"}}', ], [ "Please reset the card PIN. The new one never arrived and the old one is locked after three tries.", [["intent", "card_pin_change, card_lost, balance_inquiry", False, 0.5]], '{"intent": {"labels": {"card_pin_change": "The customer wants a new PIN or the current PIN replaced", "card_lost": "The physical card is missing", "balance_inquiry": "The customer wants the current balance"}}}', ], ], inputs=[text_in, decisions, overrides], outputs=[json_out, summary_out], fn=decide, cache_examples=True, cache_mode="lazy", label="Examples — multilingual decisions from the model card", ) demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)