CoolFace
Apppublic

Ferr0/structured-output-playground

sourceHugging Facemitupdated 3mo agoView on Hugging Face
0likes
app.py210 linesDownload Raw Back to root
1"""Structured Output Playground — lock any LLM's output to a JSON schema.2 3A local model (Qwen2.5-3B-Instruct) extracts structured data from free text. With4*constrained decoding* on (Outlines), the decoder can only emit tokens that keep the5output conformant to the schema — right keys, right types, valid enums, every time.6Flip constraints off and the same model free-styles: it may wrap JSON in a markdown7fence, or — more subtly — return valid JSON that violates the schema. That contrast8*is* the demo.9 10Runs on ZeroGPU (H200). No external API, no secrets.11"""12 13import json14import os15import time16 17import gradio as gr18import jsonschema19import spaces20import torch21import outlines22from outlines.types import JsonSchema23from transformers import AutoModelForCausalLM, AutoTokenizer24 25from examples import CONTACT_TEXT, EXAMPLES26from schemas import CUSTOM_LABEL, PRESETS, preset_schema27 28MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-3B-Instruct")29MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "384"))30 31SYSTEM_PROMPT = (32    "You are a precise information-extraction engine. You output a single JSON object and "33    "nothing else. Put each value in the field whose meaning it matches, and never copy the "34    "same value into two different fields."35)36 37DEFAULT_CUSTOM_SCHEMA = json.dumps(38    {39        "type": "object",40        "properties": {41            "summary": {"type": "string"},42            "topics": {"type": "array", "items": {"type": "string"}},43            "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},44        },45        "required": ["summary", "sentiment"],46    },47    indent=2,48)49 50print(f"[init] loading {MODEL_ID} …")51_t0 = time.perf_counter()52_tok = AutoTokenizer.from_pretrained(MODEL_ID)53_hf = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, device_map="cuda")54model = outlines.from_transformers(_hf, _tok)55print(f"[init] model ready in {time.perf_counter() - _t0:.1f}s")56 57 58def resolve_schema(preset: str, custom_schema: str) -> dict:59    if preset == CUSTOM_LABEL:60        return json.loads(custom_schema)61    return preset_schema(preset)62 63 64def build_prompt(text: str, schema: dict) -> str:65    # Naming the fields keeps the model from mis-mapping; the grammar enforces structure.66    fields = ", ".join(schema.get("properties", {}).keys())67    hint = f" with these fields: {fields}" if fields else ""68    user = (69        f"Extract the information from the text below as a JSON object{hint}.\n\n"70        f'Text:\n"""{text}"""\n\n'71        "Return only the JSON object."72    )73    return _tok.apply_chat_template(74        [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user}],75        tokenize=False,76        add_generation_prompt=True,77    )78 79 80@spaces.GPU(duration=60)81def _generate(prompt: str, preset: str, custom_schema: str, constraints_on: bool) -> str:82    # Simple, picklable args only (ZeroGPU forks a worker); rebuild the output type here.83    if not constraints_on:84        return model(prompt, max_new_tokens=MAX_NEW_TOKENS)85    if preset == CUSTOM_LABEL:86        output_type = JsonSchema(json.loads(custom_schema))87    else:88        output_type = PRESETS[preset]89    return model(prompt, output_type=output_type, max_new_tokens=MAX_NEW_TOKENS)90 91 92def extract(text, preset, custom_schema, constraints_on):93    """Extract structured data from free text as JSON that conforms to a schema.94 95    With constraints on, the output is guaranteed valid against the chosen schema96    (right keys, types and enums) via constrained decoding.97 98    Args:99        text: The free text to extract structured information from.100        preset: Which schema to use — "Contact card", "Product", "Job posting",101            "Event", or "Custom (edit the schema)".102        custom_schema: A JSON Schema string; used only when preset is the Custom option.103        constraints_on: If true, force the output to match the schema (recommended).104 105    Returns:106        The extracted JSON (string) and a short validity/status badge (markdown).107    """108    text = (text or "").strip()109    if not text:110        return "", "Paste some text first."111 112    try:113        schema = resolve_schema(preset, custom_schema)114    except json.JSONDecodeError as e:115        return "", f"❌ Your custom schema is not valid JSON: {e}"116 117    prompt = build_prompt(text, schema)118    t0 = time.perf_counter()119    raw = _generate(prompt, preset, custom_schema, constraints_on)120    dt = time.perf_counter() - t0121 122    # 1) Is it even JSON?123    try:124        parsed = json.loads(raw)125    except json.JSONDecodeError as e:126        why = (127            "the model wrapped its answer in a markdown code fence (```)"128            if raw.lstrip().startswith("```")129            else f"`{e}`"130        )131        note = (132            "But constraints were ON — please report this."133            if constraints_on134            else "Constrained decoding never emits a fence or prose — always parseable."135        )136        return raw, f"❌ **Not valid JSON** · {dt:.1f}s — {why}. {note}"137 138    # 2) Does it actually conform to the schema (types, enums, required)?139    try:140        jsonschema.validate(parsed, schema)141    except jsonschema.ValidationError as e:142        shown = json.dumps(parsed, indent=2, ensure_ascii=False)143        note = (144            "But constraints were ON — please report this."145            if constraints_on146            else "Constrained decoding would have *forced* the right type/enum here."147        )148        return shown, f"⚠️ **Valid JSON, but it breaks the schema** · {dt:.1f}s\n\n`{e.message}` — {note}"149 150    pretty = json.dumps(parsed, indent=2, ensure_ascii=False)151    extra = "" if constraints_on else " — the model complied this time, but nothing *forced* it to."152    return pretty, f"✅ **Valid & schema-conformant** · {dt:.1f}s{extra}"153 154 155def on_preset_change(preset):156    return gr.update(visible=(preset == CUSTOM_LABEL))157 158 159INTRO = """160# 🔒 Structured Output Playground161**Lock any LLM's output to a JSON schema.** A local model (Qwen2.5-3B) extracts structured data from162free text. With **constraints ON**, the decoder can only emit tokens that keep the output163**conformant to your schema** — right keys, right *types*, valid *enums*, every time.164 165Flip **OFF** and the same model free-styles: it may wrap the JSON in a markdown fence, or — more166subtly — return *valid JSON that violates your schema* (a string where you asked for an integer, a167value outside your enum). A good model complies *often*; constrained decoding makes it **always**.168 169Runs on **ZeroGPU** (H200) · no external API, no secrets · built by170[Ferr0](https://huggingface.co/Ferr0) · [pixelium.win](https://pixelium.win) · [GitHub](https://github.com/ferr079)171"""172 173with gr.Blocks(title="Structured Output Playground") as demo:174    gr.Markdown(INTRO)175 176    with gr.Row():177        with gr.Column(scale=1):178            preset = gr.Dropdown(179                choices=list(PRESETS.keys()) + [CUSTOM_LABEL],180                value="Contact card",181                label="Schema preset",182            )183            custom = gr.Code(184                value=DEFAULT_CUSTOM_SCHEMA,185                language="json",186                label="Custom JSON Schema",187                visible=False,188            )189            text = gr.Textbox(190                value=CONTACT_TEXT,191                lines=8,192                label="Source text",193                placeholder="Paste any text to extract from…",194            )195            constraints = gr.Checkbox(value=True, label="Constraints ON (force schema)")196            go = gr.Button("Extract", variant="primary")197 198        with gr.Column(scale=1):199            out = gr.Code(label="Extracted JSON", language="json")200            badge = gr.Markdown()201 202    gr.Examples(examples=EXAMPLES, inputs=[text, preset, constraints])203 204    preset.change(on_preset_change, inputs=preset, outputs=custom, api_name=False)205    go.click(extract, inputs=[text, preset, custom, constraints], outputs=[out, badge])206 207 208if __name__ == "__main__":209    demo.launch()210