Download app.py from OpceanAI/PASITA: direct link, hf CLI and curl.
- Browser
- Download file 46.5 kB
-
https://huggingface.co/spaces/OpceanAI/PASITA/resolve/main/app.py
- Command line
-
hf download hf://spaces/OpceanAI/PASITA/app.py
-
curl -L -o app.py https://huggingface.co/spaces/OpceanAI/PASITA/resolve/main/app.py
46.5 kB
| import html | |
| import json | |
| import os | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| os.environ["GRADIO_SSR_MODE"] = "false" | |
| import fastapi | |
| import spaces | |
| import torch | |
| import gradio as gr | |
| from fastapi.responses import JSONResponse, StreamingResponse | |
| from gradio import Server | |
| from gradio.context import LocalContext | |
| from pydantic import BaseModel | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| try: | |
| from gradio.route_utils import Request as GradioRequest | |
| except ImportError: | |
| GradioRequest = None | |
| from pasita import infer_kind, convert_to_markdown | |
| MODEL_ID = "OpceanAI/PASITA" | |
| MODEL_CARD = "https://huggingface.co/OpceanAI/PASITA" | |
| MAX_INPUT_CHARS = 60_000 | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) | |
| tokenizer.padding_side = "left" | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype="auto").to(DEVICE) | |
| model.eval() | |
| EMPTY_MD = "No output yet. Paste text on the left, then press Convert." | |
| DETAILS_EMPTY = "Run details will appear here: route, numbers kept, coverage and time." | |
| GLYPH = ( | |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' | |
| 'stroke-linecap="round" aria-hidden="true">' | |
| '<path d="M9 4 7 20M17 4l-2 16M4 9h16M3 15h16"/></svg>' | |
| ) | |
| def _status(text: str, state: str = "ready", detail: str = "") -> str: | |
| detail_html = f'<span class="p-detail">{html.escape(detail)}</span>' if detail else "" | |
| role = 'role="alert"' if state == "error" else 'role="status" aria-live="polite"' | |
| return ( | |
| f'<div class="p-status {state}" {role}><span class="p-dot"></span>' | |
| f'<span class="p-text">{html.escape(text)}</span>{detail_html}</div>' | |
| ) | |
| def _details_md(result: dict | None, seconds: float, error: str | None = None) -> str: | |
| if error is not None: | |
| return f"Could not convert.\n\n{error}\n\nShorten very long inputs and try again." | |
| if result is None: | |
| return DETAILS_EMPTY | |
| metrics = result["metrics"] | |
| routes = { | |
| "model": "PASITA + rerank", | |
| "structured": "HTML/table converter", | |
| "passthrough": "Source passthrough", | |
| } | |
| lines = [ | |
| f"**Route:** {routes.get(result.get('route'), result.get('route'))}", | |
| f"**Numbers kept:** {metrics['numbers_matched']} of {metrics['numbers_in']}", | |
| ] | |
| missing = metrics.get("missing_number_keys") or [] | |
| if missing: | |
| shown = ", ".join(f"`{x}`" for x in missing[:10]) | |
| more = f" (+{len(missing) - 10} more)" if len(missing) > 10 else "" | |
| lines.append(f"**Missing numbers:** {shown}{more} (check the output)") | |
| lines += [ | |
| f"**Content coverage:** {metrics['content_overlap']:.0%}", | |
| f"**Time:** {seconds:.1f}s", | |
| f"**Candidates:** {len(result.get('candidates', []))}", | |
| ] | |
| return "\n\n".join(lines) | |
| def _prune_downloads(max_age_seconds: int = 3600) -> None: | |
| try: | |
| from gradio.utils import get_cache_folder | |
| now = time.time() | |
| for path in Path(get_cache_folder()).glob("pasita-*.md"): | |
| try: | |
| if now - path.stat().st_mtime > max_age_seconds: | |
| path.unlink() | |
| except OSError: | |
| pass | |
| except Exception: | |
| pass | |
| def _write_download(markdown: str | None) -> str | None: | |
| if not markdown or not markdown.strip(): | |
| return None | |
| try: | |
| from gradio.utils import get_cache_folder | |
| _prune_downloads() | |
| folder = Path(get_cache_folder()) | |
| folder.mkdir(parents=True, exist_ok=True) | |
| path = folder / f"pasita-{uuid.uuid4().hex[:12]}.md" | |
| path.write_text(markdown, encoding="utf-8") | |
| return str(path) | |
| except Exception: | |
| return None | |
| def _counter(text: str) -> str: | |
| text = text or "" | |
| words = len(text.split()) | |
| return ( | |
| '<span class="p-label">PLAIN TEXT</span>' | |
| f'<span class="p-count">{len(text):,} CHARS / {words:,} WORDS</span>' | |
| ) | |
| def _clamp_candidates(num_candidates: int | None) -> int: | |
| if num_candidates is None: | |
| return 3 | |
| try: | |
| return max(1, min(3, int(num_candidates))) | |
| except (TypeError, ValueError): | |
| return 3 | |
| def _normalize(text: str | None, num_candidates: int | None, structured_fallback: bool | None) -> tuple[str, int, bool]: | |
| text = (text or "").strip() | |
| if structured_fallback is None: | |
| structured_fallback = True | |
| return text, _clamp_candidates(num_candidates), bool(structured_fallback) | |
| def _estimate_duration(text, num_candidates=3, structured_fallback=True, max_output_tokens=None, *args, **kwargs): | |
| text = text or "" | |
| if structured_fallback and infer_kind(text.strip()) in ("html", "data"): | |
| return 15 | |
| candidates = _clamp_candidates(num_candidates if not isinstance(num_candidates, bool) else 3) | |
| try: | |
| cap = max(16, min(512, int(max_output_tokens))) if max_output_tokens is not None else 512 | |
| except (TypeError, ValueError): | |
| cap = 512 | |
| extra = max(0, cap - 210) // 100 | |
| return min(30, max(15, 12 + len(text) // 200 + 4 * (candidates - 1) + extra)) | |
| def _run_harness(text: str | None, num_candidates: int | None, structured_fallback: bool | None, max_new_cap: int | None = None) -> dict: | |
| text, num_candidates, structured_fallback = _normalize(text, num_candidates, structured_fallback) | |
| if not text: | |
| raise ValueError("Paste a document to convert.") | |
| hard_cap = 512 | |
| if max_new_cap is not None: | |
| try: | |
| hard_cap = max(16, min(512, int(max_new_cap))) | |
| except (TypeError, ValueError): | |
| hard_cap = 512 | |
| started = time.perf_counter() | |
| result = convert_to_markdown( | |
| model, | |
| tokenizer, | |
| text, | |
| num_candidates=num_candidates, | |
| do_sample=True, | |
| temperature=0.3, | |
| top_p=0.95, | |
| seed=0, | |
| use_cache=True, | |
| max_new_tokens="auto", | |
| hard_cap=hard_cap, | |
| structured_fallback=structured_fallback, | |
| skip_model_for_structured=True, | |
| ) | |
| result["elapsed"] = time.perf_counter() - started | |
| return result | |
| def _gpu_infer(text: str | None = None, num_candidates: int | None = None, structured_fallback: bool | None = None, max_output_tokens: int | None = None) -> dict: | |
| """Run the PASITA harness on the GPU worker and return the full result dict.""" | |
| return _run_harness(text, num_candidates, structured_fallback, max_output_tokens) | |
| def _friendly_error(exc: Exception) -> tuple[str, str, str, str, None]: | |
| err_type = type(exc).__name__ | |
| text_lower = str(exc).lower() | |
| if "zerogpu" in text_lower and any(k in text_lower for k in ("quota", "exhausted", "insufficient", "limit")): | |
| plain = "The GPU quota ran out. Wait a bit or sign in, then try again." | |
| elif any(k in text_lower for k in ("illegal duration", "task aborted", "queue is full", "queue full")): | |
| plain = "The GPU was too busy. Shorten the input or try again in a bit." | |
| else: | |
| plain = f"Something failed on the server ({err_type}). Try again." | |
| return ( | |
| f"> Could not convert.\n>\n> {plain}", | |
| "", | |
| _status(f"ERROR / {err_type}", "error"), | |
| _details_md(None, 0.0, error=f"{plain} Technical detail: {err_type}: {exc}"), | |
| None, | |
| ) | |
| def convert(text: str | None = None, num_candidates: int | None = None, structured_fallback: bool | None = None, max_output_tokens: int | None = None) -> tuple[str, str, str, str, str | None]: | |
| """Convert a plain-text document into faithful GitHub-Flavored Markdown. | |
| PASITA samples the document several times, every candidate is post-processed and | |
| scored for number fidelity, content coverage and GFM validity, and the best one is | |
| returned. Inputs detected as HTML or delimited data are converted deterministically, | |
| and a run that loses source numbers falls back to a faithful passthrough of the input. | |
| Args: | |
| text: The document to convert, for example OCR output, pasted HTML, meeting notes or a report. | |
| num_candidates: How many PASITA samples to generate and rerank, from 1 to 3. | |
| structured_fallback: Use the deterministic converter for HTML and delimited data. | |
| max_output_tokens: Optional cap on generated tokens. When omitted, the length is chosen automatically. | |
| Returns: | |
| A tuple of the rendered Markdown, the raw Markdown, a status line, run details | |
| and a download path for the Markdown file (or None). | |
| """ | |
| text = (text or "").strip() | |
| if not text: | |
| return EMPTY_MD, "", _status("READY / PASTE A DOCUMENT TO CONVERT"), DETAILS_EMPTY, None | |
| if len(text) > MAX_INPUT_CHARS: | |
| message = f"Input too long ({len(text):,} chars). The limit is {MAX_INPUT_CHARS:,} characters." | |
| return ( | |
| f"> {message} Shorten the document and try again.", | |
| "", | |
| _status("ERROR / INPUT TOO LONG", "error"), | |
| _details_md(None, 0.0, error=message), | |
| None, | |
| ) | |
| try: | |
| result = _gpu_infer(text, num_candidates, structured_fallback, max_output_tokens) | |
| metrics = result["metrics"] | |
| numbers = f"{metrics['numbers_matched']}/{metrics['numbers_in']} NUMBERS" | |
| overlap = f"{metrics['content_overlap']:.0%} CONTENT" | |
| routes = { | |
| "model": "PASITA + RERANK", | |
| "structured": "HTML/TABLE CONVERTER", | |
| "passthrough": "SOURCE PASSTHROUGH", | |
| } | |
| source_label = routes.get(result.get("route"), "PASITA + RERANK") | |
| taken = result["seconds"] if result["seconds"] else result["elapsed"] | |
| detail = f"{source_label} / {numbers} / {overlap} / {taken:.1f}S" | |
| if result.get("strategy", {}).get("truncated_input"): | |
| detail += " / INPUT TRUNCATED TO 2048 TOKENS" | |
| return result["markdown"], result["markdown"], _status("DONE", "ready", detail), _details_md(result, taken), _write_download(result["markdown"]) | |
| except Exception as exc: | |
| return _friendly_error(exc) | |
| def gpu_convert(text: str | None = None, num_candidates: int | None = None, structured_fallback: bool | None = None, max_output_tokens: int | None = None) -> dict: | |
| """Convert a document and return the full conversion result. | |
| Same pipeline as the UI conversion. Used by the /v1 OpenAI-style API routes. | |
| Args: | |
| text: The document to convert. | |
| num_candidates: How many PASITA samples to generate and rerank, from 1 to 3. | |
| structured_fallback: Use the deterministic converter for HTML and delimited data. | |
| max_output_tokens: Optional cap on generated tokens. When omitted, the length is chosen automatically. | |
| Returns: | |
| The full result dict: markdown, route, metrics, timing and candidate info. | |
| """ | |
| return _run_harness(text, num_candidates, structured_fallback, max_output_tokens) | |
| API_DESCRIPTION = """ | |
| OpenAI-style REST API for [PASITA](https://huggingface.co/OpceanAI/PASITA), an 88M | |
| from-scratch model that turns plain text into faithful Markdown. No API key is required. | |
| Works with the OpenAI SDK by pointing `base_url` at `/v1`. The Gradio API and the MCP | |
| server stay available at `?view=api`. | |
| Honored request fields: `model`, `messages`, `stream`, `stream_options.include_usage`, | |
| `max_tokens` (upper bound), `candidates` (1-3), `structured_fallback`. Sampling | |
| temperature is fixed at 0.3 because that is the measured recipe; other OpenAI fields | |
| are accepted but ignored. | |
| """.strip() | |
| def _api_error(message: str, error_type: str = "invalid_request_error", status: int = 400, code: str | None = None, headers: dict | None = None) -> JSONResponse: | |
| return JSONResponse( | |
| status_code=status, | |
| content={"error": {"message": message, "type": error_type, "param": None, "code": code}}, | |
| headers=headers, | |
| ) | |
| def _exception_to_error(exc: Exception) -> JSONResponse: | |
| message = f"{type(exc).__name__}: {exc}" | |
| text = message.lower() | |
| if "zerogpu" in text and any(k in text for k in ("quota", "exhausted", "insufficient", "limit")): | |
| return _api_error(str(exc), "rate_limit_error", status=429, code="gpu_quota_exceeded", headers={"Retry-After": "60"}) | |
| if any(k in text for k in ("illegal duration", "task aborted", "queue is full", "queue full")): | |
| return _api_error(str(exc), "rate_limit_error", status=429, code="gpu_busy", headers={"Retry-After": "30"}) | |
| return _api_error(message, "server_error", status=500) | |
| def _with_request_context(request: fastapi.Request, text: str, candidates: int, fallback: bool, max_tokens: int | None = None) -> dict: | |
| if GradioRequest is not None: | |
| gr_request = GradioRequest(request=request, session_hash="api") | |
| token = LocalContext.request.set(gr_request) | |
| try: | |
| return gpu_convert(text, candidates, fallback, max_tokens) | |
| finally: | |
| LocalContext.request.reset(token) | |
| return gpu_convert(text, candidates, fallback, max_tokens) | |
| def _token_counts(text: str, markdown: str, prompt_tokens: int | None = None) -> tuple[int, int]: | |
| if not prompt_tokens: | |
| prompt_tokens = len(tokenizer(text, add_special_tokens=True)["input_ids"]) | |
| completion_tokens = len(tokenizer(markdown, add_special_tokens=False)["input_ids"]) | |
| return prompt_tokens, completion_tokens | |
| class ChatMessage(BaseModel): | |
| role: str = "user" | |
| content: str | list | None = None | |
| class ChatCompletionRequest(BaseModel): | |
| model: str | None = "pasita-v1" | |
| messages: list[ChatMessage] | |
| stream: bool = False | |
| stream_options: dict | None = None | |
| max_tokens: int | None = None | |
| candidates: int = 3 | |
| structured_fallback: bool = True | |
| class ConvertRequest(BaseModel): | |
| text: str | |
| candidates: int = 3 | |
| structured_fallback: bool = True | |
| max_tokens: int | None = None | |
| server = Server(title="PASITA API", version="1.0.0", description=API_DESCRIPTION) | |
| _V2_CONVERT_PATHS = frozenset({"/gradio_api/call/v2/convert", "/gradio_api/call/v2/convert/"}) | |
| _V1_CONVERT_PATHS = frozenset({"/gradio_api/call/convert", "/gradio_api/call/convert/"}) | |
| _QUEUE_JOIN_PATHS = frozenset({"/gradio_api/queue/join", "/gradio_api/queue/join/"}) | |
| _CONVERT_API_NAMES = frozenset({"convert", "convert_1"}) | |
| def _is_convert_fn(scope, fn_index) -> bool: | |
| try: | |
| app = scope.get("app") | |
| if app is None or not hasattr(app, "get_blocks"): | |
| return False | |
| blocks = app.get_blocks() | |
| fns = getattr(blocks, "fns", None) | |
| if not fns or not isinstance(fn_index, int) or isinstance(fn_index, bool): | |
| return False | |
| if not 0 <= fn_index < len(fns): | |
| return False | |
| return getattr(fns[fn_index], "api_name", None) in _CONVERT_API_NAMES | |
| except Exception: | |
| return False | |
| _CONVERT_PARAMS = (("text", ""), ("num_candidates", 3), ("structured_fallback", True), ("max_output_tokens", None)) | |
| def _clean_candidates(value) -> int: | |
| try: | |
| ivalue = int(value) | |
| except (TypeError, ValueError): | |
| return 3 | |
| return ivalue if 1 <= ivalue <= 3 else 3 | |
| def _clean_flag(value) -> bool: | |
| if value is None or value == "": | |
| return True | |
| if isinstance(value, bool): | |
| return value | |
| if isinstance(value, (int, float)): | |
| return bool(value) | |
| if isinstance(value, str): | |
| lowered = value.strip().lower() | |
| if lowered in ("true", "1", "yes", "on"): | |
| return True | |
| if lowered in ("false", "0", "no", "off"): | |
| return False | |
| return True | |
| def _clean_convert_value(name: str, value): | |
| if name == "num_candidates": | |
| return _clean_candidates(value) | |
| if name == "structured_fallback": | |
| return _clean_flag(value) | |
| if name == "max_output_tokens": | |
| if value is None or value == "": | |
| return None | |
| try: | |
| ivalue = int(value) | |
| except (TypeError, ValueError): | |
| return None | |
| return max(16, min(512, ivalue)) | |
| return value | |
| class _LegacyConvertBodyMiddleware: | |
| def __init__(self, app): | |
| self.app = app | |
| async def __call__(self, scope, receive, send): | |
| if scope.get("type") != "http" or scope.get("method") != "POST": | |
| await self.app(scope, receive, send) | |
| return | |
| path = scope.get("path", "") | |
| is_v2 = path in _V2_CONVERT_PATHS | |
| is_v1 = path in _V1_CONVERT_PATHS | |
| is_queue = path in _QUEUE_JOIN_PATHS | |
| if not (is_v2 or is_v1 or is_queue): | |
| await self.app(scope, receive, send) | |
| return | |
| body = b"" | |
| while True: | |
| message = await receive() | |
| body += message.get("body", b"") | |
| if not message.get("more_body"): | |
| break | |
| try: | |
| parsed = json.loads(body.decode("utf-8")) if body else None | |
| except Exception: | |
| parsed = None | |
| if isinstance(parsed, dict): | |
| if is_queue and not _is_convert_fn(scope, parsed.get("fn_index")): | |
| pass | |
| else: | |
| if is_v2 and "data" in parsed and "text" not in parsed: | |
| data = parsed.get("data") | |
| if isinstance(data, list): | |
| named: dict = {} | |
| for (name, _default), value in zip(_CONVERT_PARAMS, data): | |
| named[name] = value | |
| for key in ("session_hash", "event_id", "fn_index", "trigger_id", "batched"): | |
| if key in parsed: | |
| named[key] = parsed[key] | |
| parsed = named | |
| if is_v2: | |
| for name, default in _CONVERT_PARAMS: | |
| if name in parsed and (parsed[name] is None or parsed[name] == ""): | |
| parsed[name] = default | |
| for name in ("num_candidates", "structured_fallback", "max_output_tokens"): | |
| if name in parsed: | |
| parsed[name] = _clean_convert_value(name, parsed[name]) | |
| elif isinstance(parsed.get("data"), list): | |
| data = list(parsed["data"]) | |
| while len(data) < len(_CONVERT_PARAMS): | |
| data.append(None) | |
| cleaned = [] | |
| for (name, default), value in zip(_CONVERT_PARAMS, data): | |
| if value is None or value == "": | |
| cleaned.append(default if name != "text" else "") | |
| else: | |
| cleaned.append(_clean_convert_value(name, value)) | |
| parsed = dict(parsed) | |
| parsed["data"] = cleaned | |
| body = json.dumps(parsed, default=str).encode("utf-8") | |
| async def receive_once(): | |
| return {"type": "http.request", "body": body, "more_body": False} | |
| headers = [(k, v) for k, v in scope.get("headers", []) if k.lower() != b"content-length"] | |
| headers.append((b"content-length", str(len(body)).encode("latin-1"))) | |
| scope["headers"] = headers | |
| await self.app(scope, receive_once, send) | |
| server.add_middleware(_LegacyConvertBodyMiddleware) | |
| try: | |
| from fastapi.middleware.cors import CORSMiddleware | |
| server.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["GET", "POST", "OPTIONS"], | |
| allow_headers=["*"], | |
| ) | |
| except Exception: | |
| pass | |
| async def validation_error_handler(request: fastapi.Request, exc: fastapi.exceptions.RequestValidationError): | |
| issues = "; ".join(f"{'.'.join(str(l) for l in e.get('loc', []))}: {e.get('msg', '')}" for e in exc.errors()[:3]) | |
| return _api_error(f"Invalid request body: {issues}", status=400) | |
| def list_models() -> dict: | |
| return { | |
| "object": "list", | |
| "data": [{"id": "pasita-v1", "object": "model", "created": 1760000000, "owned_by": "OpceanAI"}], | |
| } | |
| def health() -> dict: | |
| return {"status": "ok", "model": "pasita-v1"} | |
| def _validate(body: ChatCompletionRequest | ConvertRequest, text: str | None) -> JSONResponse | None: | |
| if text is None or not text.strip(): | |
| return _api_error("No input text was provided.", code="empty_input") | |
| if len(text) > MAX_INPUT_CHARS: | |
| return _api_error( | |
| f"The document is too long ({len(text):,} chars). The limit is {MAX_INPUT_CHARS:,} characters.", | |
| code="input_too_long", | |
| ) | |
| candidates = getattr(body, "candidates", 3) | |
| try: | |
| candidates = int(candidates) | |
| except (TypeError, ValueError): | |
| return _api_error("candidates must be between 1 and 3.", code="invalid_candidates") | |
| if not 1 <= candidates <= 3: | |
| return _api_error("candidates must be between 1 and 3.", code="invalid_candidates") | |
| return None | |
| def _result_payload(result: dict, text: str, started: float) -> dict: | |
| markdown = result["markdown"] | |
| prompt_tokens, completion_tokens = _token_counts(text, markdown, result.get("prompt_tokens")) | |
| return { | |
| "markdown": markdown, | |
| "route": result.get("route", "model"), | |
| "metrics": result["metrics"], | |
| "candidates": len(result.get("candidates", [])), | |
| "seconds": round(result["seconds"] or result["elapsed"], 2), | |
| "usage": { | |
| "prompt_tokens": prompt_tokens, | |
| "completion_tokens": completion_tokens, | |
| "total_tokens": prompt_tokens + completion_tokens, | |
| }, | |
| "elapsed": round(time.perf_counter() - started, 2), | |
| } | |
| def v1_convert(body: ConvertRequest, request: fastapi.Request) -> dict | JSONResponse: | |
| error = _validate(body, body.text) | |
| if error is not None: | |
| return error | |
| started = time.perf_counter() | |
| try: | |
| result = _with_request_context(request, body.text.strip(), int(body.candidates), bool(body.structured_fallback), body.max_tokens) | |
| except Exception as exc: | |
| return _exception_to_error(exc) | |
| payload = _result_payload(result, body.text.strip(), started) | |
| payload.update({"id": f"conv-{uuid.uuid4().hex[:24]}", "object": "pasita.conversion", "created": int(time.time()), "model": "pasita-v1"}) | |
| return payload | |
| def _extract_user_text(messages: list[ChatMessage]) -> str | None: | |
| for message in reversed(messages): | |
| if message.role != "user": | |
| continue | |
| content = message.content | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for part in content: | |
| if isinstance(part, dict) and part.get("type") == "text": | |
| parts.append(str(part.get("text", ""))) | |
| elif isinstance(part, str): | |
| parts.append(part) | |
| joined = "\n".join(p for p in parts if p) | |
| if joined: | |
| return joined | |
| continue | |
| continue | |
| return None | |
| def reset() -> tuple[str, str, str, str, str | None, str]: | |
| return EMPTY_MD, "", _status("READY"), DETAILS_EMPTY, None, _counter("") | |
| def chat_completions(body: ChatCompletionRequest, request: fastapi.Request) -> dict | JSONResponse | StreamingResponse: | |
| text = _extract_user_text(body.messages) | |
| error = _validate(body, text) | |
| if error is not None: | |
| return error | |
| started = time.perf_counter() | |
| try: | |
| result = _with_request_context(request, text.strip(), int(body.candidates), bool(body.structured_fallback), body.max_tokens) | |
| except Exception as exc: | |
| return _exception_to_error(exc) | |
| payload = _result_payload(result, text.strip(), started) | |
| completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" | |
| created = int(time.time()) | |
| markdown = payload["markdown"] | |
| if body.stream: | |
| include_usage = bool((body.stream_options or {}).get("include_usage")) | |
| def sse(): | |
| def chunk(delta, finish=None, usage=None): | |
| data = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": "pasita-v1", | |
| "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], | |
| } | |
| if usage is not None: | |
| data["usage"] = usage | |
| return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" | |
| yield chunk({"role": "assistant", "content": ""}) | |
| yield chunk({"content": markdown}) | |
| yield chunk({}, finish="stop", usage=payload["usage"] if include_usage else None) | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse( | |
| sse(), | |
| media_type="text/event-stream", | |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, | |
| ) | |
| return { | |
| "id": completion_id, | |
| "object": "chat.completion", | |
| "created": created, | |
| "model": "pasita-v1", | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": {"role": "assistant", "content": markdown}, | |
| "logprobs": None, | |
| "finish_reason": "stop", | |
| } | |
| ], | |
| "usage": payload["usage"], | |
| "pasita": { | |
| "route": payload["route"], | |
| "metrics": payload["metrics"], | |
| "candidates": payload["candidates"], | |
| "seconds": payload["seconds"], | |
| }, | |
| } | |
| PAINT_JS = """(text, num_candidates, structured_fallback) => { | |
| const n = Number(num_candidates) || 1; | |
| const label = n > 1 ? 'PASITA ' + n + ' SAMPLES / RERANK' : 'PASITA SAMPLE / RERANK'; | |
| return ['Converting…', '', | |
| '<div class="p-status busy"><span class="p-dot"></span><span class="p-text">CONVERTING</span><span class="p-detail">' + label + '</span></div>', | |
| 'Checking drafts…', null]; | |
| }""" | |
| def _failed(*args) -> tuple[str, str, str, str, None]: | |
| message = "The request did not complete. Check your connection and try again." | |
| return ( | |
| EMPTY_MD, | |
| "", | |
| _status("ERROR / CONNECTION LOST", "error"), | |
| _details_md(None, 0.0, error=message), | |
| None, | |
| ) | |
| COUNTER_JS = """(text) => { | |
| const t = (text || '').trim(); | |
| const words = t ? t.split(/\\s+/).length : 0; | |
| const chars = (text || '').length; | |
| const cls = chars > 60000 ? 'p-count over' : (chars > 50000 ? 'p-count warn' : 'p-count'); | |
| return '<span class="p-label">PLAIN TEXT</span><span class="' + cls + '">' + chars.toLocaleString() + ' CHARS / ' + words.toLocaleString() + ' WORDS</span>'; | |
| }""" | |
| HEADER = f""" | |
| <header class="p-head"> | |
| <div class="p-brand"> | |
| <span class="p-glyph">{GLYPH}</span> | |
| <h1 class="p-name">PASITA</h1> | |
| <span class="p-tag">/ 88M FROM SCRATCH</span> | |
| <span class="sr-only">Convert plain text, HTML and notes to Markdown</span> | |
| </div> | |
| <div class="p-actions"> | |
| <a class="p-btn" href="{MODEL_CARD}" target="_blank" rel="noopener">MODEL CARD</a> | |
| </div> | |
| </header> | |
| """ | |
| FOOTER = f""" | |
| <footer class="p-foot"> | |
| <span>PASITA v1 converts plain text to Markdown, in Spanish and English. It works best on medium and long documents. Limit 60,000 characters per conversion.</span> | |
| <span> | |
| <a href="{MODEL_CARD}" target="_blank" rel="noopener">MODEL</a> / | |
| <a href="/docs" target="_blank" rel="noopener">API DOCS</a> / | |
| <a href="?view=api" target="_blank" rel="noopener">GRADIO API</a> / | |
| RUNS ON ZEROGPU | |
| </span> | |
| </footer> | |
| """ | |
| EXAMPLES = [ | |
| [ | |
| """RESUMEN MENSUAL DE OPERACIONES: MARZO 2026 | |
| Resumen ejecutivo | |
| Los pedidos gestionados alcanzaron 18.420 unidades, un 6,1% más que en febrero. El tiempo medio de preparación bajó a 2,4 horas. | |
| Detalle por almacén | |
| Madrid: 8.120 pedidos (+7,4%) | |
| Barcelona: 5.480 pedidos (+3,9%) | |
| Lisboa: 4.820 pedidos (+6,7%) | |
| Incidencias | |
| Se registraron 96 incidencias de embalaje, el 0,52% del total. La previsión para abril es de 19.100 pedidos.""" | |
| ], | |
| [ | |
| """Team sync, 2026-08-14 | |
| Attendees: Ana, Marc, Priya, Tom | |
| Decisions | |
| 1. Ship the new onboarding flow on Sept 2. | |
| 2. Postpone the pricing experiment to Q4. | |
| 3. Marc owns the migration checklist; due Aug 22. | |
| Open questions | |
| - Do we need a second staging cluster? | |
| - Who signs off on the SOC2 evidence?""" | |
| ], | |
| [ | |
| """<h2>Shipping policy</h2><p>Orders placed before 2:00 PM CET ship the same business day.</p><ul><li>Standard delivery: 3 to 5 business days</li><li>Express delivery: 1 business day</li><li>Free shipping over 49 EUR</li></ul><p>Returns are accepted within 30 days with the original packaging.</p>""" | |
| ], | |
| [ | |
| """year,region,revenue,growth | |
| 2019,North America,5120.4,4.1% | |
| 2019,Europe,4380.9,3.7% | |
| 2019,Latin America,2105.2,2.9% | |
| 2020,North America,4875.6,-4.8% | |
| 2020,Europe,4210.3,-3.9% | |
| 2020,Latin America,1988.1,-5.6% | |
| 2021,North America,5402.7,10.8% | |
| 2021,Europe,4605.5,9.4% | |
| 2021,Latin America,2240.8,12.7% | |
| 2022,North America,5610.2,3.8% | |
| 2022,Europe,4720.6,2.5% | |
| 2022,Latin America,2385.4,6.5% | |
| 2023,North America,5894.9,5.1% | |
| 2023,Europe,4903.7,3.9% | |
| 2023,Latin America,2560.3,7.3%""" | |
| ], | |
| ] | |
| CSS = """@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| :root, .gradio-container { | |
| --bg:#0a0a0b; --panel:#101013; --panel-2:#16161a; --obsidian:#0d0d10; | |
| --ink:#f4f4f5; --muted:#a1a1aa; --muted-2:#8e8e96; | |
| --gold:#e8b34c; --green:#4cc38a; --red:#f87171; | |
| --hair:rgba(255,255,255,0.09); --hair-soft:rgba(255,255,255,0.06); | |
| --panel-hover:rgba(255,255,255,0.05); | |
| --font-display:"Inter", system-ui, sans-serif; | |
| --font-mono:"JetBrains Mono", ui-monospace, monospace; | |
| --body-background-fill:var(--bg); | |
| --background-fill-primary:var(--panel); | |
| --background-fill-secondary:var(--panel-2); | |
| --block-background-fill:transparent; | |
| --block-border-color:transparent; | |
| --block-border-width:0px; | |
| --block-radius:12px; | |
| --border-color-primary:var(--hair); | |
| --body-text-color:var(--ink); | |
| --body-text-color-subdued:var(--muted); | |
| --input-background-fill:transparent; | |
| --input-border-color:transparent; | |
| --input-radius:8px; | |
| --color-accent:var(--gold); | |
| --color-accent-soft:rgba(232,179,76,0.12); | |
| --button-primary-background-fill:#fafafa; | |
| --button-primary-background-fill-hover:#e4e4e7; | |
| --button-primary-text-color:#0a0a0b; | |
| --button-secondary-background-fill:transparent; | |
| --button-secondary-text-color:var(--muted); | |
| --button-secondary-border-color:var(--hair); | |
| --button-large-radius:8px; | |
| --button-small-radius:8px; | |
| --checkbox-background-color-selected:var(--gold); | |
| --checkbox-border-color-focus:var(--gold); | |
| --shadow-drop:none; --shadow-drop-lg:none; | |
| --font:var(--font-display); --font-mono:var(--font-mono); | |
| } | |
| body, .gradio-container { | |
| background: var(--bg) !important; | |
| color: var(--ink); | |
| font-family: var(--font-display); | |
| font-variant-numeric: tabular-nums; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| .gradio-container { max-width: 1200px !important; margin: 0 auto; padding: 0 20px 26px; } | |
| footer[aria-label="Gradio footer navigation"] { display: none !important; } | |
| :focus-visible { outline: 2px solid var(--gold) !important; outline-offset: 2px; } | |
| .p-head { | |
| display: flex; align-items: center; justify-content: space-between; gap: 16px; | |
| padding: 20px 0 14px; border-bottom: 1px solid var(--hair-soft); margin-bottom: 20px; | |
| } | |
| .p-brand { display: flex; align-items: center; gap: 10px; } | |
| .p-glyph { display: inline-flex; width: 20px; height: 20px; color: var(--gold); } | |
| .p-glyph svg { width: 100%; height: 100%; } | |
| .p-name { font-weight: 600; letter-spacing: 0.12em; font-size: 0.95rem; margin: 0; } | |
| .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| .p-tag { font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.12em; color: var(--muted-2); margin-top: 2px; } | |
| .p-actions { display: flex; align-items: center; gap: 10px; } | |
| .p-btn { | |
| font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em; | |
| color: var(--ink); text-decoration: none; padding: 0 16px; min-height: 44px; | |
| display: inline-flex; align-items: center; border-radius: 8px; | |
| background: var(--panel); border: 1px solid var(--hair); | |
| transition: background .2s ease, color .2s ease, border-color .2s ease; | |
| } | |
| .p-btn:hover { background: var(--panel-2); border-color: rgba(255,255,255,0.22); } | |
| .p-status { | |
| display: flex; align-items: center; gap: 9px; margin: -6px 0 16px; | |
| font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; | |
| text-transform: uppercase; color: var(--muted); min-height: 20px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .p-dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,0.2); flex-shrink: 0; } | |
| .p-status.ready .p-dot { background: var(--green); } | |
| .p-status.busy .p-dot { background: var(--gold); animation: p-pulse 1.1s ease-in-out infinite; } | |
| .p-status.error { color: var(--red); } | |
| .p-status.error .p-dot { background: var(--red); } | |
| .p-detail { margin-left: auto; color: var(--muted-2); } | |
| @keyframes p-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } | |
| #p-shell { gap: 16px; align-items: stretch; } | |
| .p-panel { | |
| background: var(--panel); border: 1px solid var(--hair); border-radius: 12px; | |
| padding: 14px 14px 12px; min-width: 0; | |
| } | |
| .p-label { font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); } | |
| .p-count { float: right; font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.02em; color: var(--muted-2); font-variant-numeric: tabular-nums; } | |
| .p-count.warn { color: var(--gold); } | |
| .p-count.over { color: var(--red); } | |
| .p-out-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; } | |
| #p-dl { font-family: var(--font-mono) !important; font-size: 0.7rem !important; letter-spacing: 0.08em; min-height: 40px; } | |
| #p-text, #p-text > div { background: transparent !important; border: 0 !important; box-shadow: none !important; } | |
| #p-text textarea { | |
| background: transparent !important; border: 0 !important; color: var(--ink) !important; | |
| font-family: var(--font-display) !important; font-size: 0.94rem; line-height: 1.65; | |
| min-height: 400px; padding: 12px 2px 0; | |
| } | |
| #p-text textarea::placeholder { color: var(--muted-2) !important; } | |
| #p-run, #p-clear { font-family: var(--font-mono) !important; font-size: 0.75rem !important; letter-spacing: 0.1em; min-height: 46px; } | |
| #p-run { flex: 2; font-weight: 500; } | |
| #p-clear { flex: 1; background: transparent !important; color: var(--muted) !important; border: 1px solid var(--hair) !important; } | |
| #p-clear:hover { color: var(--ink) !important; border-color: rgba(255,255,255,0.25) !important; } | |
| #p-examples .label { font-family: var(--font-mono) !important; font-size: 0.66rem !important; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted) !important; } | |
| #p-examples .label svg { display: none; } | |
| #p-examples .gallery-item { | |
| background: var(--panel-2) !important; border: 1px solid var(--hair) !important; border-radius: 8px !important; | |
| color: var(--muted) !important; font-size: 12px !important; min-height: 44px; padding: 8px 14px !important; | |
| text-align: left; transition: background .2s ease, color .2s ease, border-color .2s ease; | |
| } | |
| #p-examples .gallery-item:hover { border-color: rgba(255,255,255,0.25) !important; color: var(--ink) !important; } | |
| #p-tabs .tab-container[role="tablist"] { | |
| display: inline-flex; gap: 2px; background: var(--panel-2); border: 1px solid var(--hair); | |
| border-radius: 10px; padding: 3px; margin-bottom: 12px; | |
| } | |
| #p-tabs .tab-container[role="tablist"] button { | |
| appearance: none; border: 0 !important; background: transparent !important; border-radius: 7px !important; | |
| padding: 7px 14px !important; font-family: var(--font-mono) !important; font-size: 12px !important; | |
| letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted) !important; min-height: 44px; | |
| } | |
| #p-tabs .tab-container[role="tablist"] button.selected, | |
| #p-tabs .tab-container[role="tablist"] button[aria-selected="true"] { | |
| background: rgba(255,255,255,0.09) !important; color: var(--gold) !important; | |
| } | |
| #p-tabs .tab-container::after, | |
| #p-tabs .tab-container button.selected::after { content: none !important; } | |
| #p-rendered { min-height: 360px; font-size: 0.94rem; line-height: 1.7; color: var(--ink); } | |
| #p-rendered .prose { font-size: 0.94rem; line-height: 1.7; } | |
| #p-rendered h1, #p-rendered h2, #p-rendered h3, #p-rendered h4 { color: var(--ink); font-weight: 600; line-height: 1.3; margin: 1.2em 0 0.5em; letter-spacing: -0.01em; } | |
| #p-rendered h1 { font-size: 1.45em; } | |
| #p-rendered h2 { font-size: 1.22em; } | |
| #p-rendered h3 { font-size: 1.08em; } | |
| #p-rendered p { margin: 0 0 0.85em; } | |
| #p-rendered ul, #p-rendered ol { margin: 0 0 0.85em; padding-left: 1.5em; } | |
| #p-rendered li { margin: 0.22em 0; } | |
| #p-rendered li::marker { color: var(--muted-2); } | |
| #p-rendered a { color: var(--gold); text-decoration: underline; text-underline-offset: 2px; } | |
| #p-rendered a:hover { color: #f2c578; } | |
| #p-rendered blockquote { margin: 0 0 0.85em; padding: 2px 0 2px 14px; border-left: 2px solid var(--hair); color: var(--muted); } | |
| #p-rendered hr { border: 0; border-top: 1px solid var(--hair); margin: 1.2em 0; } | |
| #p-rendered code { font-family: var(--font-mono); font-size: 0.82em; background: var(--panel-2); border: 1px solid var(--hair); border-radius: 6px; padding: 1px 5px; } | |
| #p-rendered pre { margin: 0 0 0.85em; padding: 14px 16px; background: var(--obsidian); border: 1px solid var(--hair); border-radius: 8px; overflow-x: auto; } | |
| #p-rendered pre code { background: none; border: 0; padding: 0; font-size: 0.82em; line-height: 1.6; } | |
| #p-rendered table { border-collapse: collapse; margin: 0 0 0.85em; font-size: 0.9em; display: block; overflow-x: auto; } | |
| #p-rendered th, #p-rendered td { border: 1px solid var(--hair); padding: 6px 11px; text-align: left; font-variant-numeric: tabular-nums; } | |
| #p-rendered th { background: var(--panel-2); color: var(--ink); font-weight: 600; } | |
| #p-source { | |
| background: var(--obsidian) !important; border: 1px solid var(--hair-soft) !important; | |
| border-radius: 8px !important; font-family: var(--font-mono) !important; min-height: 360px; | |
| } | |
| #p-source label[data-testid="block-label"] { display: none; } | |
| #p-source .cm-editor { background: transparent !important; } | |
| #p-source .cm-content, #p-source .cm-scroller { color: var(--ink) !important; caret-color: var(--gold) !important; } | |
| #p-source .cm-gutters { background: transparent !important; color: var(--muted-2) !important; border-right: 1px solid var(--hair-soft) !important; } | |
| #p-settings { background: transparent !important; border: 1px solid var(--hair-soft) !important; border-radius: 8px !important; margin-top: 4px; } | |
| #p-settings > .label-wrap { font-family: var(--font-mono) !important; font-size: 0.68rem !important; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted) !important; } | |
| #p-settings > .label-wrap .icon { color: var(--muted-2) !important; } | |
| #p-settings .info-text { font-size: 0.72rem !important; color: var(--muted-2) !important; } | |
| #p-candidates .wrap { display: inline-flex; gap: 2px; background: var(--panel-2); border: 1px solid var(--hair); border-radius: 10px; padding: 3px; } | |
| #p-candidates label { border-radius: 7px; padding: 7px 16px; margin: 0 !important; cursor: pointer; transition: background .2s ease, color .2s ease; } | |
| #p-candidates label span { font-family: var(--font-mono) !important; font-size: 12px !important; letter-spacing: 0.06em; color: var(--muted) !important; font-variant-numeric: tabular-nums; } | |
| #p-candidates label.selected { background: rgba(255,255,255,0.09) !important; } | |
| #p-candidates label.selected span { color: var(--gold) !important; } | |
| #p-candidates input[type="radio"] { accent-color: var(--gold); } | |
| .p-foot { | |
| display: flex; flex-wrap: wrap; justify-content: space-between; gap: 14px; | |
| margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--hair-soft); | |
| font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.04em; line-height: 1.7; color: var(--muted-2); | |
| } | |
| .p-foot a { color: var(--muted); text-decoration: none; } | |
| .p-foot a:hover { color: var(--gold); } | |
| @media (max-width: 820px) { | |
| .gradio-container { padding: 0 14px 20px; } | |
| #p-shell { flex-direction: column !important; } | |
| .p-tag { display: none; } | |
| #p-text textarea { min-height: 220px; } | |
| #p-rendered, #p-source { min-height: 240px; } | |
| .p-status { flex-wrap: wrap; } | |
| .p-detail { margin-left: 0; width: 100%; } | |
| .p-foot { flex-direction: column; gap: 8px; } | |
| #p-runrow { position: sticky; bottom: 0; background: var(--panel); padding: 8px 0; z-index: 5; } | |
| #p-examples .gallery-item { min-height: 48px; } | |
| } | |
| @media print { | |
| body, .gradio-container { background: #fff !important; color: #000 !important; } | |
| #p-shell { display: block !important; } | |
| #p-text, #p-settings, #p-examples, #p-runrow, .p-actions { display: none !important; } | |
| .p-panel { border: 0 !important; background: transparent !important; } | |
| .p-head { border-bottom: 1px solid #000 !important; } | |
| #p-rendered, #p-rendered .prose { color: #000 !important; } | |
| #p-rendered a { color: #000 !important; } | |
| #p-rendered th { background: transparent !important; } | |
| #p-rendered code, #p-rendered pre { background: #f4f4f5 !important; border-color: #ccc !important; } | |
| #p-source .cm-content { color: #000 !important; } | |
| .p-status, .p-foot { display: none !important; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { animation: none !important; transition: none !important; } | |
| } | |
| """ | |
| theme = gr.themes.Base( | |
| primary_hue=gr.themes.colors.orange, | |
| neutral_hue=gr.themes.colors.gray, | |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], | |
| ) | |
| with gr.Blocks(title="PASITA: plain text to Markdown") as demo: | |
| gr.HTML(HEADER) | |
| status = gr.HTML(_status("READY"), elem_id="p-status") | |
| with gr.Row(elem_id="p-shell"): | |
| with gr.Column(scale=1, min_width=320, elem_classes=["p-panel"]): | |
| label_in = gr.HTML(_counter("")) | |
| text_in = gr.Textbox( | |
| label="Input document", | |
| show_label=False, | |
| container=False, | |
| placeholder="Paste OCR output, pasted HTML, meeting notes, a report or an article... (SHIFT + ENTER to convert)", | |
| lines=20, | |
| max_lines=26, | |
| elem_id="p-text", | |
| ) | |
| with gr.Accordion("SETTINGS", open=False, elem_id="p-settings"): | |
| candidates = gr.Radio( | |
| choices=[1, 2, 3], | |
| value=3, | |
| type="value", | |
| label="PASITA CANDIDATES", | |
| info="More samples improve the reranker. 3 is the measured default.", | |
| elem_id="p-candidates", | |
| ) | |
| structured = gr.Checkbox( | |
| value=True, | |
| label="DETERMINISTIC CONVERTER FOR HTML AND TABLES", | |
| info="PASITA does not handle raw HTML or data dumps at this size.", | |
| ) | |
| with gr.Row(elem_id="p-runrow"): | |
| run = gr.Button("CONVERT TO MARKDOWN", variant="primary", elem_id="p-run") | |
| clear = gr.Button("CLEAR", elem_id="p-clear") | |
| with gr.Column(scale=1, min_width=320, elem_classes=["p-panel"]): | |
| with gr.Tabs(elem_id="p-tabs"): | |
| with gr.Tab("RENDERED", elem_id="p-tab-rendered"): | |
| md_out = gr.Markdown(EMPTY_MD, buttons=["copy"], height=520, elem_id="p-rendered") | |
| with gr.Tab("SOURCE", elem_id="p-tab-source"): | |
| source = gr.Code( | |
| value="", language="markdown", buttons=["copy", "download"], | |
| wrap_lines=True, lines=24, max_lines=24, elem_id="p-source", | |
| ) | |
| with gr.Tab("DETAILS", elem_id="p-tab-details"): | |
| details = gr.Markdown(DETAILS_EMPTY, elem_id="p-details") | |
| with gr.Row(elem_classes=["p-out-head"]): | |
| out_label = gr.HTML('<span class="p-label">MARKDOWN</span>') | |
| dl = gr.DownloadButton("DOWNLOAD .MD", size="sm", elem_id="p-dl") | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| example_labels=["ES REPORT", "EN MEETING", "HTML", "CSV"], | |
| inputs=[text_in], | |
| outputs=[md_out, source, status, details, dl], | |
| fn=convert, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| examples_per_page=4, | |
| label="EXAMPLES", | |
| elem_id="p-examples", | |
| ) | |
| gr.HTML(FOOTER) | |
| run.click( | |
| None, | |
| inputs=[text_in, candidates, structured], | |
| outputs=[md_out, source, status, details, dl], | |
| js=PAINT_JS, | |
| show_progress="hidden", | |
| queue=False, | |
| ).then( | |
| convert, | |
| inputs=[text_in, candidates, structured], | |
| outputs=[md_out, source, status, details, dl], | |
| api_name="convert", | |
| show_progress="minimal", | |
| ).failure( | |
| _failed, | |
| inputs=None, | |
| outputs=[md_out, source, status, details, dl], | |
| show_progress="hidden", | |
| api_visibility="undocumented", | |
| ) | |
| text_in.submit( | |
| None, | |
| inputs=[text_in, candidates, structured], | |
| outputs=[md_out, source, status, details, dl], | |
| js=PAINT_JS, | |
| show_progress="hidden", | |
| queue=False, | |
| ).then( | |
| convert, | |
| inputs=[text_in, candidates, structured], | |
| outputs=[md_out, source, status, details, dl], | |
| api_visibility="undocumented", | |
| show_progress="minimal", | |
| ).failure( | |
| _failed, | |
| inputs=None, | |
| outputs=[md_out, source, status, details, dl], | |
| show_progress="hidden", | |
| api_visibility="undocumented", | |
| ) | |
| text_in.change(None, inputs=[text_in], outputs=[label_in], js=COUNTER_JS, api_visibility="undocumented") | |
| clear.click(reset, inputs=None, outputs=[md_out, source, status, details, dl, label_in], api_visibility="undocumented") | |
| demo.queue() | |
| demo.launch(_app=server, theme=theme, css=CSS, mcp_server=True) | |