dataforge-model / app.py
Praneshrajan15's picture
Fix CPU generate: tokenize string prompt (BatchEncoding-safe)
5828643 verified
Raw
History Blame Contribute Delete
17.1 kB
"""Gradio ZeroGPU Space for the DataForge-0.5B checkpoint.
This Space serves two audiences from one loaded checkpoint:
* a **human demo** (`Detect + propose fixes`) that takes a CSV snippet and shows
what the model proposes, and
* a **stable programmatic API** (`generate`, `health`) that the DataForge
playground drives, one GPU round-trip per agent step, through the torch-free
remote policy. The API contract is deliberately small and version-stable:
`generate(messages_json, temperature, max_new_tokens) -> assistant text`.
The checkpoint defaults to the verified GRPO model
(`Praneshrajan15/DataForge-0.5B-GRPO`); override with `DATAFORGE_SPACE_MODEL_ID`.
Nothing here applies repairs, stores data, or bypasses the DataForge safety and
SMT verification path -- those run on the caller (the playground API or CLI).
"""
from __future__ import annotations
import csv
import io
import json
import os
from collections.abc import Callable
from typing import Any
import gradio as gr
try:
import spaces
except ImportError: # pragma: no cover - local development fallback
class _SpacesFallback:
"""Compatibility shim for non-Space local runs."""
@staticmethod
def GPU( # noqa: N802 - mirrors the Hugging Face spaces API.
*args: object,
**kwargs: object,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Return an identity decorator when the HF `spaces` package is absent."""
del args, kwargs
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
spaces = _SpacesFallback()
MODEL_ID = os.environ.get("DATAFORGE_SPACE_MODEL_ID", "Praneshrajan15/DataForge-0.5B-GRPO")
MAX_ROWS = 50
MAX_NEW_TOKENS_CAP = 512
MAX_MESSAGES = 32
MAX_MESSAGE_CHARS = 8000
EXAMPLE_SNIPPETS = [
"id,amount,department\n1,100,cardiology\n2,105,cardiology\n3,1020,cardiology",
"id,email,zip\n1,ana@example.com,02139\n2,bob@example.com,2139\n3,chen@example.com,02139",
"id,room,ward\n1,12A,north\n2,12A,north\n3,99Z,south",
]
TABLE_HEADERS = [
"status",
"row",
"column",
"issue_type",
"old_value",
"new_value",
"confidence",
"reason",
]
SYSTEM_PROMPT = (
"You are DataForge-0.5B. Given a CSV snippet, return JSON only. "
"Use either a list of repair objects or {'fixes': [...]} with keys row, "
"column, issue_type, old_value, new_value, confidence, reason. If no repair "
"is justified, return an empty list."
)
# Loaded once per Space process (populated inside the first GPU call, where CUDA
# is available on ZeroGPU) so multi-step agent loops reuse weights instead of
# re-instantiating the model on every round-trip.
_MODEL_CACHE: dict[str, Any] = {}
def _table_row(
*,
status: str,
row: str = "",
column: str = "",
issue_type: str = "",
old_value: str = "",
new_value: str = "",
confidence: str = "",
reason: str = "",
) -> list[str]:
"""Build one stable output-table row."""
return [status, row, column, issue_type, old_value, new_value, confidence, reason]
def parse_csv_snippet(csv_snippet: str) -> tuple[bool, str, list[dict[str, str]]]:
"""Parse and validate a CSV snippet submitted to the demo.
Args:
csv_snippet: Raw CSV text from the Gradio textbox.
Returns:
Tuple of `(ok, message, rows)`. When `ok` is false, `message` is safe to
show in the UI and `rows` is empty.
"""
if not csv_snippet.strip():
return False, "Paste a CSV snippet with a header row and up to 50 data rows.", []
try:
reader = csv.DictReader(io.StringIO(csv_snippet))
if reader.fieldnames is None or not any(name for name in reader.fieldnames):
return False, "CSV must include a header row.", []
rows = [dict(row) for row in reader]
except csv.Error as exc:
return False, f"CSV could not be parsed: {exc}", []
if not rows:
return False, "CSV must include at least one data row.", []
if len(rows) > MAX_ROWS:
return False, f"CSV snippet has {len(rows)} rows; the demo accepts at most {MAX_ROWS}.", []
return True, "CSV accepted.", rows
def _json_candidates(text: str) -> list[Any]:
"""Return JSON payload candidates parsed from a model response."""
stripped = text.strip()
candidates: list[Any] = []
for candidate in (stripped, _extract_json_block(stripped)):
if not candidate:
continue
try:
candidates.append(json.loads(candidate))
except json.JSONDecodeError:
continue
return candidates
def _extract_json_block(text: str) -> str | None:
"""Extract the outermost JSON-looking block from model text."""
starts = [index for index in (text.find("["), text.find("{")) if index >= 0]
if not starts:
return None
start = min(starts)
end = max(text.rfind("]"), text.rfind("}"))
if end <= start:
return None
return text[start : end + 1]
def parse_model_output(model_text: str) -> list[list[str]]:
"""Normalize model output into stable table rows."""
for payload in _json_candidates(model_text):
raw_items: Any
if isinstance(payload, dict):
raw_items = payload.get("fixes", payload.get("issues", []))
else:
raw_items = payload
if not isinstance(raw_items, list):
continue
rows: list[list[str]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
rows.append(
_table_row(
status="proposed",
row=str(item.get("row", "")),
column=str(item.get("column", "")),
issue_type=str(item.get("issue_type", item.get("detector_id", ""))),
old_value=str(item.get("old_value", item.get("actual", ""))),
new_value=str(item.get("new_value", item.get("expected", ""))),
confidence=str(item.get("confidence", "")),
reason=str(item.get("reason", "")),
)
)
return rows or [_table_row(status="ok", reason="The model returned no proposed fixes.")]
preview = model_text.strip().replace("\n", " ")
if len(preview) > 240:
preview = preview[:237] + "..."
return [_table_row(status="raw", reason=preview or "The model returned an empty response.")]
def _coerce_messages(messages_json: str) -> list[dict[str, str]]:
"""Validate and normalize a chat payload for the `generate` API.
Accepts a JSON array of `{"role", "content"}` objects, a `{"messages": [...]}`
wrapper, or a bare string (treated as a single user turn). Roles are clamped
to the chat set and content is length-capped so a single call cannot exhaust
the GPU budget.
"""
raw = messages_json.strip()
if not raw:
raise ValueError("messages payload is empty")
try:
parsed: Any = json.loads(raw)
except json.JSONDecodeError:
parsed = [{"role": "user", "content": raw}]
if isinstance(parsed, dict):
parsed = parsed.get("messages", [parsed])
if not isinstance(parsed, list) or not parsed:
raise ValueError("messages must be a non-empty list")
if len(parsed) > MAX_MESSAGES:
raise ValueError(f"too many messages ({len(parsed)} > {MAX_MESSAGES})")
out: list[dict[str, str]] = []
for item in parsed:
if not isinstance(item, dict):
raise ValueError("each message must be a JSON object")
role = str(item.get("role", "user"))
if role not in {"system", "user", "assistant"}:
role = "user"
content = str(item.get("content", ""))
if len(content) > MAX_MESSAGE_CHARS:
content = content[:MAX_MESSAGE_CHARS]
out.append({"role": role, "content": content})
return out
def _load_model() -> tuple[Any, Any]:
"""Load (and cache) the tokenizer and model for this Space process."""
if "model" not in _MODEL_CACHE:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model_kwargs: dict[str, Any] = {}
if torch.cuda.is_available():
model_kwargs["torch_dtype"] = torch.float16
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, **model_kwargs)
_MODEL_CACHE["tokenizer"] = tokenizer
_MODEL_CACHE["model"] = model
return _MODEL_CACHE["tokenizer"], _MODEL_CACHE["model"]
def _run_chat(
messages: list[dict[str, str]],
*,
temperature: float,
max_new_tokens: int,
) -> str:
"""Run a chat completion against the loaded checkpoint and return the text."""
import torch
tokenizer, model = _load_model()
if torch.cuda.is_available():
model = model.to("cuda")
device = next(model.parameters()).device
try:
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
except Exception:
prompt = (
"\n".join(f"{message['role']}: {message['content']}" for message in messages)
+ "\nassistant:"
)
inputs = tokenizer(prompt, return_tensors="pt").to(device)
gen_kwargs: dict[str, Any] = {
"max_new_tokens": max_new_tokens,
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
}
if temperature and temperature > 0:
gen_kwargs["do_sample"] = True
gen_kwargs["temperature"] = temperature
else:
gen_kwargs["do_sample"] = False
outputs = model.generate(**inputs, **gen_kwargs)
generated = outputs[0][inputs["input_ids"].shape[-1] :]
text = tokenizer.decode(generated, skip_special_tokens=True)
if torch.cuda.is_available():
torch.cuda.empty_cache()
return str(text)
def _generate_model_text(csv_snippet: str) -> str:
"""Run the checkpoint on a CSV snippet for the human demo path."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"CSV:\n{csv_snippet.strip()}\n\nJSON:"},
]
return _run_chat(messages, temperature=0.0, max_new_tokens=384)
@spaces.GPU(duration=60)
def detect_and_propose(csv_snippet: str) -> list[list[str]]:
"""Detect data-quality issues and propose fixes for a CSV snippet."""
ok, message, _rows = parse_csv_snippet(csv_snippet)
if not ok:
return [_table_row(status="error", reason=message)]
try:
model_text = _generate_model_text(csv_snippet)
except Exception as exc:
return [_table_row(status="error", reason=f"Model inference failed: {exc}")]
return parse_model_output(model_text)
def detect_and_propose_with_status(csv_snippet: str) -> tuple[list[list[str]], str]:
"""Return model proposals plus an honest demo-status message."""
rows = detect_and_propose(csv_snippet)
first_status = rows[0][0] if rows else "raw"
if first_status == "error":
return rows, "Input rejected or inference failed. The verified playground path remains Profile -> Repair -> Verify -> Revert."
if first_status == "raw":
return rows, "The checkpoint returned unstructured text. Treat this as research output, not a verified repair."
if first_status == "ok":
return rows, "The checkpoint proposed no fixes for this snippet."
return rows, f"Experimental checkpoint returned {len(rows)} proposed fix row(s). Verify repairs with the CLI or playground API before trusting them."
@spaces.GPU(duration=60)
def generate(
messages_json: str,
temperature: float = 0.0,
max_new_tokens: float = 384,
) -> str:
"""Stable chat-completion endpoint driven by the DataForge agent loop.
Args:
messages_json: JSON array of `{"role", "content"}` chat messages (or a
bare string treated as a single user turn).
temperature: Sampling temperature; `<= 0` selects greedy decoding so the
agent's deterministic floor stays reproducible.
max_new_tokens: Requested generation cap, clamped to `MAX_NEW_TOKENS_CAP`.
Returns:
The assistant text completion with the chat scaffolding removed.
Raises:
gr.Error: If the payload is invalid or inference fails, so remote callers
observe a clear transport-level error and can degrade gracefully.
"""
try:
messages = _coerce_messages(str(messages_json))
except ValueError as exc:
raise gr.Error(f"invalid messages payload: {exc}") from exc
capped = max(1, min(int(max_new_tokens), MAX_NEW_TOKENS_CAP))
try:
return _run_chat(messages, temperature=float(temperature), max_new_tokens=capped)
except Exception as exc: # pragma: no cover - surfaced to the remote caller
raise gr.Error(f"inference failed: {exc}") from exc
def health() -> str:
"""Return a JSON capability descriptor for the remote policy (no GPU)."""
return json.dumps(
{
"status": "ok",
"model_id": MODEL_ID,
"max_new_tokens_cap": MAX_NEW_TOKENS_CAP,
"max_messages": MAX_MESSAGES,
"api": ["generate", "health"],
}
)
with gr.Blocks(title="DataForge 0.5B") as demo:
gr.Markdown(
"""
# DataForge 0.5B (GRPO)
Experimental model demo for short CSV snippets, serving the verified GRPO
checkpoint. This Space shows what the checkpoint proposes and exposes a stable
`generate` API for the DataForge playground agent; it does not apply repairs,
store data, or replace the verified DataForge workflow.
**Use the product path for evidence:** Profile -> Repair -> Verify -> Revert
in the CLI or playground. Safety filtering and SMT verification run on the
caller, not here. This model surface is intentionally bounded to 50 rows, one
queued inference at a time, and research-grade outputs (GRPO correction F1 is
low; treat proposals as unverified until the caller checks them).
"""
)
with gr.Row():
with gr.Column(scale=2):
csv_input = gr.Textbox(
label="CSV snippet",
lines=14,
max_lines=20,
placeholder="id,amount\n1,100\n2,105\n3,1020",
)
gr.Examples(
examples=EXAMPLE_SNIPPETS,
inputs=csv_input,
label="Audited examples",
)
run_button = gr.Button("Detect + propose fixes", variant="primary")
with gr.Column(scale=3):
output = gr.Dataframe(
headers=TABLE_HEADERS,
datatype=["str"] * len(TABLE_HEADERS),
row_count=1,
column_count=len(TABLE_HEADERS),
label="Model output",
)
status_output = gr.Markdown("Waiting for a CSV snippet.")
run_button.click(
detect_and_propose_with_status,
inputs=csv_input,
outputs=[output, status_output],
show_progress="full",
concurrency_limit=1,
)
with gr.Accordion("Agent API (programmatic)", open=False):
gr.Markdown(
"These endpoints back the DataForge playground agent. `generate` "
"takes a JSON chat payload and returns the assistant text; `health` "
"reports the served model id and caps. They are stable API names; "
"the UI controls below are for manual inspection only."
)
messages_input = gr.Textbox(
label="messages (JSON)",
lines=6,
value='[{"role": "user", "content": "Return an empty JSON list: []"}]',
)
with gr.Row():
temperature_input = gr.Number(label="temperature", value=0.0)
max_new_tokens_input = gr.Number(label="max_new_tokens", value=384)
generate_button = gr.Button("generate")
generate_output = gr.Textbox(label="completion", lines=6)
generate_button.click(
generate,
inputs=[messages_input, temperature_input, max_new_tokens_input],
outputs=generate_output,
api_name="generate",
concurrency_limit=1,
)
health_button = gr.Button("health")
health_output = gr.Textbox(label="health", lines=3)
health_button.click(health, inputs=None, outputs=health_output, api_name="health")
demo.queue(max_size=8, default_concurrency_limit=1)
if __name__ == "__main__":
demo.launch()