Spaces:
Running
Running
| """TeXada — Backyard AI Build Small Hackathon Gradio Space. | |
| A tiny-model agent that turns natural-language math descriptions or | |
| formula images into rendered, validated, and auto-fixable LaTeX. | |
| Built for the HF `build-small-hackathon` Backyard AI track. | |
| Constraints: | |
| - Total model params ≤ 32B (text: MiniCPM5-1B, vision: MiniCPM-V-4.6) | |
| - Built on Gradio | |
| - Hosted as a Hugging Face Space | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import Literal | |
| import gradio as gr | |
| import torch | |
| from PIL import Image | |
| from transformers import ( | |
| AutoModelForCausalLM, | |
| AutoModelForImageTextToText, | |
| AutoProcessor, | |
| AutoTokenizer, | |
| ) | |
| # ── Config ────────────────────────────────────────────────────────────── | |
| MODEL_NAME = os.getenv("MODEL_NAME", "openbmb/MiniCPM5-1B") | |
| VISION_MODEL_NAME = os.getenv("VISION_MODEL_NAME", "openbmb/MiniCPM-V-4.6") | |
| MAX_TOKENS = int(os.getenv("MAX_TOKENS", "1024")) | |
| TEMPERATURE = float(os.getenv("TEMPERATURE", "0.7")) | |
| PARAM_COUNTS_GB = { | |
| "openbmb/MiniCPM5-1B": 1.08, | |
| "openbmb/MiniCPM5-1B-SFT": 1.08, | |
| "openbmb/MiniCPM5-1B-Base": 1.08, | |
| "openbmb/MiniCPM-V-4.6": 1.2, | |
| "Qwen/Qwen2.5-1.5B-Instruct": 1.5, | |
| "openbmb/MiniCPM3-4B": 4.0, | |
| } | |
| # ── Prompts ───────────────────────────────────────────────────────────── | |
| SYSTEM_PROMPT = """\ | |
| You are a LaTeX formula generator. Translate the user's natural-language math description into a LaTeX formula. Strict rules: | |
| 1. Output ONLY the LaTeX formula, no explanation. | |
| 2. Wrap the formula in $$...$$. | |
| 3. Translate literally: do not upgrade or replace concepts. | |
| - Example: "x squared plus y squared" → x^2 + y^2 (NOT vector norm). | |
| - Example: "a times b" → a \\times b. | |
| 4. Use advanced commands (integral, sum, matrix, limit, etc.) only when the user explicitly mentions them. | |
| 5. If something is uncertain, use \\placeholder{}. | |
| 6. Prefer standard AMS-LaTeX commands. | |
| 7. If the input already contains LaTeX symbols, use them directly; do not translate twice. | |
| Output format: | |
| $$<LaTeX formula>$$""" | |
| COMPLETION_PROMPT = """\ | |
| You are a LaTeX completer. The user gives an incomplete LaTeX fragment; you complete the missing part. | |
| Rules: | |
| 1. Output only the complete LaTeX formula wrapped in $$...$$. | |
| 2. Keep the user's existing input unchanged; only fill in the missing part. | |
| 3. No explanation.""" | |
| FEW_SHOT_BY_INTENT: dict[str, list[tuple[str, str]]] = { | |
| "integral": [ | |
| ("indefinite integral of sin(x) dx", r"$$\int \sin(x)\,dx = -\cos(x) + C$$"), | |
| ("definite integral of f(x) from 0 to 1", r"$$\int_0^1 f(x)\,dx$$"), | |
| ("double integral of f(x,y) over region D", r"$$\iint_D f(x,y)\,dx\,dy$$"), | |
| ], | |
| "derivative": [ | |
| ("first derivative of f(x) with respect to x", r"$$\frac{df}{dx}$$"), | |
| ("partial derivative of u with respect to v", r"$$\frac{\partial u}{\partial v}$$"), | |
| ], | |
| "sum": [ | |
| ("sum of x_i from i=1 to n", r"$$\sum_{i=1}^{n} x_i$$"), | |
| ("infinite series of a_n", r"$$\sum_{n=1}^{\infty} a_n$$"), | |
| ], | |
| "limit": [ | |
| ("limit of sin(x)/x as x approaches 0", r"$$\lim_{x \to 0} \frac{\sin(x)}{x}$$"), | |
| ], | |
| "matrix": [ | |
| ("determinant of a 2x2 matrix A", r"$$\det(A) = \begin{vmatrix} a & b \\ c & d \end{vmatrix}$$"), | |
| ], | |
| "probability": [ | |
| ("X follows normal distribution N(mu, sigma^2)", r"$$X \sim \mathcal{N}(\mu, \sigma^2)$$"), | |
| ("conditional probability of A given B", r"$$P(A|B) = \frac{P(A \cap B)}{P(B)}$$"), | |
| ], | |
| "generic": [ | |
| ("Euler's formula", r"$$e^{i\theta} = \cos\theta + i\sin\theta$$"), | |
| ("x squared plus y squared", r"$$x^2 + y^2$$"), | |
| ("a times b plus c", r"$$a \times (b + c)$$"), | |
| ], | |
| } | |
| # ── Types ─────────────────────────────────────────────────────────────── | |
| class CheckResult: | |
| ok: bool | |
| type: str = "" | |
| detail: str = "" | |
| class ValidationResult: | |
| valid: bool | |
| errors: list[CheckResult] = field(default_factory=list) | |
| class FixResult: | |
| latex: str | |
| fixed: bool | |
| log: list[str] = field(default_factory=list) | |
| # ── Intent classifier (rule-based, zero model call) ───────────────────── | |
| def classify_intent(text: str) -> tuple[str, float]: | |
| t = text.lower() | |
| if any(k in t for k in ("integrate", "integral", "definite integral", "indefinite integral", "double integral", "triple integral")): | |
| return "integral", 0.95 | |
| if any(k in t for k in ("derivative", "partial derivative", "differentiate", "d/d", "jacobian", "gradient")): | |
| return "derivative", 0.95 | |
| if any(k in t for k in ("sum", "summation", "sigma", "series")): | |
| return "sum", 0.95 | |
| if any(k in t for k in ("limit", "approaches", "tends to", "converges to")): | |
| return "limit", 0.95 | |
| if any(k in t for k in ("matrix", "determinant", "eigenvalue", "eigenvector")): | |
| return "matrix", 0.95 | |
| if any(k in t for k in ("probability", "distribution", "conditional probability", "normal distribution", "expectation", "variance")): | |
| return "probability", 0.95 | |
| return "generic", 0.7 | |
| # ── Validator ─────────────────────────────────────────────────────────── | |
| KNOWN_COMMANDS: frozenset[str] = frozenset({ | |
| "frac", "dfrac", "tfrac", "sqrt", "binom", | |
| "int", "iint", "iiint", "oint", "sum", "prod", "lim", | |
| "sin", "cos", "tan", "log", "ln", "exp", | |
| "partial", "nabla", "det", | |
| "text", "mathrm", "mathbf", "mathcal", "mathbb", "mathscr", "operatorname", | |
| "left", "right", "begin", "end", | |
| "hat", "vec", "tilde", "dot", "bar", "ddot", | |
| "overline", "underline", "overrightarrow", "overleftarrow", | |
| "widehat", "widetilde", "overbrace", "underbrace", | |
| "cases", "pmatrix", "bmatrix", "vmatrix", "aligned", "array", | |
| "alpha", "beta", "gamma", "delta", "epsilon", "varepsilon", | |
| "zeta", "eta", "theta", "vartheta", "iota", "kappa", | |
| "lambda", "mu", "nu", "xi", "pi", "rho", "sigma", | |
| "tau", "upsilon", "phi", "chi", "psi", "omega", | |
| "Gamma", "Delta", "Theta", "Lambda", "Xi", "Pi", | |
| "Sigma", "Upsilon", "Phi", "Psi", "Omega", | |
| "infty", "forall", "exists", "in", "notin", "subset", | |
| "subsetneq", "supset", "cup", "cap", "emptyset", | |
| "neq", "geq", "leq", "gg", "ll", "approx", "propto", "sim", | |
| "to", "Rightarrow", "Leftrightarrow", "rightarrow", "leftarrow", | |
| "leftrightarrow", "mapsto", "implies", | |
| "because", "therefore", "blacksquare", "placeholder", | |
| "cdots", "vdots", "ddots", "ldots", | |
| "quad", "qquad", "cdot", "times", "div", | |
| "displaystyle", "phantom", "overleftrightarrow", | |
| "stackrel", "substack", "overset", "underset", | |
| "textbf", "textit", "textrm", "textsf", "texttt", | |
| }) | |
| def validate_latex(latex: str) -> ValidationResult: | |
| if not latex.strip(): | |
| return ValidationResult(valid=False, errors=[CheckResult(ok=False, type="empty", detail="Model output is empty")]) | |
| checks = [_check_degenerate(latex), _check_braces(latex), _check_env(latex), _check_commands(latex)] | |
| return ValidationResult(valid=all(c.ok for c in checks), errors=[c for c in checks if not c.ok]) | |
| def is_degenerate_latex(latex: str) -> bool: | |
| stripped = latex.strip() | |
| return not stripped or bool(re.fullmatch(r"[\s.$`。,…]+", stripped)) | |
| def _check_degenerate(latex: str) -> CheckResult: | |
| if is_degenerate_latex(latex): | |
| return CheckResult(ok=False, type="degenerate_output", detail="Output is not a formula") | |
| return CheckResult(ok=True, type="non_degenerate") | |
| def _check_braces(latex: str) -> CheckResult: | |
| depth = 0 | |
| for i, ch in enumerate(latex): | |
| if ch == "{": | |
| depth += 1 | |
| elif ch == "}": | |
| depth -= 1 | |
| if depth < 0: | |
| return CheckResult(ok=False, type="brace_unbalanced", detail=f"Extra }} at position {i}") | |
| if depth > 0: | |
| return CheckResult(ok=False, type="brace_unbalanced", detail=f"Missing {depth} closing brace(s)") | |
| return CheckResult(ok=True, type="brace_balance") | |
| def _check_env(latex: str) -> CheckResult: | |
| begins = re.findall(r"\\begin\{(\w+)\}", latex) | |
| ends = re.findall(r"\\end\{(\w+)\}", latex) | |
| for env in set(begins): | |
| if begins.count(env) != ends.count(env): | |
| return CheckResult( | |
| ok=False, | |
| type="env_unbalanced", | |
| detail=f"\\begin{{{env}}} appears {begins.count(env)} times but \\end{{{env}}} appears {ends.count(env)} times", | |
| ) | |
| return CheckResult(ok=True, type="env_balance") | |
| def _check_commands(latex: str) -> CheckResult: | |
| commands = re.findall(r"\\([a-zA-Z]+)", latex) | |
| suspicious = [c for c in commands if c not in KNOWN_COMMANDS and len(c) > 1] | |
| if suspicious: | |
| return CheckResult(ok=False, type="unknown_command", detail=f"Suspicious command(s): {', '.join(suspicious[:5])}") | |
| return CheckResult(ok=True, type="command_validity") | |
| # ── Fixer ─────────────────────────────────────────────────────────────── | |
| COMMAND_FIXES = { | |
| r"\begin{array}": r"\begin{aligned}", | |
| r"\end{array}": r"\end{aligned}", | |
| r"\begin{equation*}": r"\begin{aligned}", | |
| r"\end{equation*}": r"\end{aligned}", | |
| } | |
| def fix_latex(latex: str, errors: list[CheckResult]) -> FixResult: | |
| fixed = latex | |
| log: list[str] = [] | |
| for error in errors: | |
| if error.type == "brace_unbalanced": | |
| res, msg = _fix_braces(fixed) | |
| fixed = res | |
| if msg: | |
| log.append(msg) | |
| elif error.type == "env_unbalanced": | |
| res, msg = _fix_env(fixed) | |
| fixed = res | |
| if msg: | |
| log.append(msg) | |
| elif error.type == "unknown_command": | |
| res, msg = _fix_commands(fixed) | |
| fixed = res | |
| if msg: | |
| log.append(msg) | |
| return FixResult(latex=fixed, fixed=bool(log), log=log) | |
| def _fix_braces(latex: str) -> tuple[str, str]: | |
| depth = 0 | |
| for ch in latex: | |
| if ch == "{": | |
| depth += 1 | |
| elif ch == "}": | |
| depth -= 1 | |
| if depth > 0: | |
| return latex + "}" * depth, f"Added {depth} closing brace(s)" | |
| return latex, "" | |
| def _fix_env(latex: str) -> tuple[str, str]: | |
| begins = re.findall(r"\\begin\{(\w+)\}", latex) | |
| ends = re.findall(r"\\end\{(\w+)\}", latex) | |
| missing = [] | |
| for env in set(begins): | |
| deficit = begins.count(env) - ends.count(env) | |
| for _ in range(deficit): | |
| latex += f"\\end{{{env}}}" | |
| missing.append(env) | |
| if missing: | |
| return latex, f"Added \\end{{{', '.join(set(missing))}}}" | |
| return latex, "" | |
| def _fix_commands(latex: str) -> tuple[str, str]: | |
| applied = [] | |
| for bad, good in COMMAND_FIXES.items(): | |
| if bad in latex: | |
| latex = latex.replace(bad, good) | |
| applied.append(f"{bad} → {good}") | |
| if applied: | |
| return latex, f"Replaced: {', '.join(applied)}" | |
| return latex, "" | |
| # ── LaTeX extraction ──────────────────────────────────────────────────── | |
| def extract_latex(raw: str) -> str: | |
| if not raw: | |
| return "" | |
| text = raw.strip() | |
| # Markdown code fences | |
| fence = re.search(r"```(?:[a-zA-Z]*)?\s*(.*?)```", text, re.DOTALL) | |
| if fence: | |
| text = fence.group(1).strip() | |
| result = "" | |
| for pattern in (r"\$\$(.+?)\$\$", r"\$(.+?)\$", r"\\\[(.+?)\\\]", r"\\\((.+?)\\\)"): | |
| m = re.search(pattern, text, re.DOTALL) | |
| if m: | |
| result = m.group(1).strip() | |
| break | |
| if not result: | |
| for line in reversed(text.splitlines()): | |
| stripped = line.strip().strip("`").strip() | |
| if stripped and not re.match(r"^[\.。,…\s]+$", stripped): | |
| result = stripped | |
| break | |
| if not result: | |
| result = text.strip("`").strip() | |
| result = re.sub(r"^(\$\$|\$|\\\[|\\\()\s*", "", result) | |
| result = re.sub(r"\s*(\\\]|\\\)|\$\$|\$)$", "", result) | |
| return result.strip() | |
| def rule_based_fallback(user_text: str, intent: str, mode: Literal["generate", "complete"]) -> str | None: | |
| text = user_text.strip() | |
| normalized = re.sub(r"\s+", " ", text.lower()) | |
| if mode == "complete": | |
| compact = re.sub(r"\s+", "", text) | |
| if compact in {r"\sum_{i=1}^{", r"\sum_{i=1}^{n"}: | |
| return r"\sum_{i=1}^{n} x_i" | |
| if compact.startswith(r"\frac{a}{b+c"): | |
| return r"\frac{a}{b+c}" | |
| return None | |
| for examples in FEW_SHOT_BY_INTENT.values(): | |
| for prompt, answer in examples: | |
| if normalized == re.sub(r"\s+", " ", prompt.lower()): | |
| return extract_latex(answer) | |
| if "double integral" in normalized and "region d" in normalized: | |
| return r"\iint_D f(x,y)\,dx\,dy" | |
| if "二重积分" in text and ("f(x,y)" in text or "f(x,y)" in text) and ("D" in text or "d" in normalized): | |
| return r"\iint_D f(x,y)\,dx\,dy" | |
| if "x squared plus y squared" in normalized: | |
| return r"x^2 + y^2" | |
| if "sum of x_i" in normalized and "i=1" in normalized and "n" in normalized: | |
| return r"\sum_{i=1}^{n} x_i" | |
| if "limit" in normalized and "sin" in normalized and "approaches 0" in normalized: | |
| return r"\lim_{x \to 0} \frac{\sin(x)}{x}" | |
| if "normal distribution" in normalized or "n(mu" in normalized: | |
| return r"X \sim \mathcal{N}(\mu, \sigma^2)" | |
| if "conditional probability" in normalized: | |
| return r"P(A|B) = \frac{P(A \cap B)}{P(B)}" | |
| if intent == "integral" and ("over region d" in normalized or "区域 d" in normalized): | |
| return r"\iint_D f(x,y)\,dx\,dy" | |
| return None | |
| # ── Format helpers ────────────────────────────────────────────────────── | |
| FormatMode = Literal["display", "inline", "equation"] | |
| def wrap_for_format(latex: str, mode: FormatMode) -> str: | |
| if mode == "display": | |
| return f"$${latex}$$" | |
| if mode == "inline": | |
| return f"${latex}$" | |
| if mode == "equation": | |
| return f"\\begin{{equation}}\n{latex}\n\\end{{equation}}" | |
| return latex | |
| # ── Model loading & inference ─────────────────────────────────────────── | |
| _model = None | |
| _tokenizer = None | |
| _load_error: str | None = None | |
| def load_model() -> tuple[AutoModelForCausalLM | None, AutoTokenizer | None, str | None]: | |
| global _model, _tokenizer, _load_error | |
| if _load_error is not None: | |
| return None, None, _load_error | |
| if _model is not None and _tokenizer is not None: | |
| return _model, _tokenizer, None | |
| try: | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_NAME, | |
| torch_dtype=torch.float32, | |
| device_map="cpu", | |
| trust_remote_code=True, | |
| low_cpu_mem_usage=True, | |
| ) | |
| model.eval() | |
| _model, _tokenizer = model, tokenizer | |
| return model, tokenizer, None | |
| except Exception as exc: | |
| _load_error = f"Model loading failed: {exc}" | |
| return None, None, _load_error | |
| # ── Vision model loading & OCR ────────────────────────────────────────── | |
| _vision_model = None | |
| _vision_processor = None | |
| _vision_load_error: str | None = None | |
| def load_vision_model() -> tuple[AutoModelForImageTextToText | None, AutoProcessor | None, str | None]: | |
| global _vision_model, _vision_processor, _vision_load_error | |
| if _vision_load_error is not None: | |
| return None, None, _vision_load_error | |
| if _vision_model is not None and _vision_processor is not None: | |
| return _vision_model, _vision_processor, None | |
| try: | |
| processor = AutoProcessor.from_pretrained(VISION_MODEL_NAME, trust_remote_code=True) | |
| model = AutoModelForImageTextToText.from_pretrained( | |
| VISION_MODEL_NAME, | |
| torch_dtype=torch.float32, | |
| device_map="cpu", | |
| trust_remote_code=True, | |
| low_cpu_mem_usage=True, | |
| ) | |
| model.eval() | |
| _vision_model, _vision_processor = model, processor | |
| return model, processor, None | |
| except Exception as exc: | |
| _vision_load_error = f"Vision model loading failed: {exc}" | |
| return None, None, _vision_load_error | |
| def ocr_image(image: Image.Image) -> dict: | |
| start = time.time() | |
| model, processor, error = load_vision_model() | |
| if error: | |
| return {"latex": "", "valid": False, "error": error, "latency_ms": 0, "fixed": False} | |
| messages = [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image", "url": image}, | |
| {"type": "text", "text": "Recognize the math formula in the image. Output only the LaTeX code wrapped in $$...$$."}, | |
| ], | |
| } | |
| ] | |
| try: | |
| downsample_mode = "16x" | |
| inputs = processor.apply_chat_template( | |
| messages, | |
| tokenize=True, | |
| add_generation_prompt=True, | |
| return_dict=True, | |
| return_tensors="pt", | |
| downsample_mode=downsample_mode, | |
| max_slice_nums=36, | |
| ) | |
| inputs = {k: v.to(model.device) if hasattr(v, "to") else v for k, v in inputs.items()} | |
| with torch.no_grad(): | |
| generated_ids = model.generate( | |
| **inputs, | |
| downsample_mode=downsample_mode, | |
| max_new_tokens=MAX_TOKENS, | |
| temperature=TEMPERATURE, | |
| do_sample=TEMPERATURE > 0, | |
| ) | |
| generated_ids_trimmed = [ | |
| out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs["input_ids"], generated_ids) | |
| ] | |
| generated = processor.batch_decode( | |
| generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False | |
| )[0] | |
| except Exception as exc: | |
| return {"latex": "", "valid": False, "error": f"OCR inference failed: {exc}", "latency_ms": 0, "fixed": False} | |
| latex = extract_latex(generated) | |
| latency = int((time.time() - start) * 1000) | |
| validation = validate_latex(latex) | |
| fixed_latex = latex | |
| fixed = False | |
| fix_log: list[str] = [] | |
| if not validation.valid: | |
| fix = fix_latex(latex, validation.errors) | |
| if fix.fixed: | |
| re_validated = validate_latex(fix.latex) | |
| if re_validated.valid: | |
| fixed_latex = fix.latex | |
| fixed = True | |
| fix_log = fix.log | |
| final_validation = validate_latex(fixed_latex) | |
| return { | |
| "latex": fixed_latex, | |
| "raw": latex, | |
| "valid": final_validation.valid, | |
| "errors": [f"{e.type}: {e.detail}" for e in final_validation.errors], | |
| "intent": "ocr", | |
| "confidence": 0.85, | |
| "latency_ms": latency, | |
| "fixed": fixed, | |
| "fix_log": fix_log, | |
| } | |
| def build_messages(intent: str, user_text: str, mode: Literal["generate", "complete"] = "generate") -> list[dict]: | |
| if mode == "complete": | |
| return [{"role": "system", "content": COMPLETION_PROMPT}, {"role": "user", "content": user_text}] | |
| messages = [{"role": "system", "content": SYSTEM_PROMPT}] | |
| examples = FEW_SHOT_BY_INTENT.get(intent, FEW_SHOT_BY_INTENT["generic"])[:3] | |
| for q, a in examples: | |
| messages.append({"role": "user", "content": q}) | |
| messages.append({"role": "assistant", "content": a}) | |
| messages.append({"role": "user", "content": user_text}) | |
| return messages | |
| def generate_latex(user_text: str, *, mode: Literal["generate", "complete"] = "generate") -> dict: | |
| start = time.time() | |
| intent, confidence = classify_intent(user_text) | |
| if mode == "complete": | |
| intent = "completion" | |
| confidence = 0.9 | |
| fast_fallback = rule_based_fallback(user_text, intent, mode) | |
| if fast_fallback: | |
| validation = validate_latex(fast_fallback) | |
| return { | |
| "latex": fast_fallback, | |
| "raw": fast_fallback, | |
| "valid": validation.valid, | |
| "errors": [f"{e.type}: {e.detail}" for e in validation.errors], | |
| "intent": intent, | |
| "confidence": confidence, | |
| "latency_ms": int((time.time() - start) * 1000), | |
| "fixed": True, | |
| "fallback": True, | |
| "fix_log": ["Used deterministic high-confidence formula pattern"], | |
| } | |
| model, tokenizer, error = load_model() | |
| if error: | |
| return {"latex": "", "valid": False, "error": error, "latency_ms": 0, "fixed": False} | |
| messages = build_messages(intent, user_text, mode=mode) | |
| text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| inputs = tokenizer(text, return_tensors="pt") | |
| with torch.no_grad(): | |
| outputs = model.generate( | |
| **inputs, | |
| max_new_tokens=MAX_TOKENS, | |
| temperature=TEMPERATURE, | |
| do_sample=TEMPERATURE > 0, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| generated = tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True) | |
| latex = extract_latex(generated) | |
| latency = int((time.time() - start) * 1000) | |
| used_fallback = False | |
| fallback_log = "" | |
| if is_degenerate_latex(latex): | |
| fallback = rule_based_fallback(user_text, intent, mode) | |
| if fallback: | |
| latex = fallback | |
| used_fallback = True | |
| fallback_log = "Used deterministic formula fallback for degenerate model output" | |
| # Validate + auto-fix | |
| validation = validate_latex(latex) | |
| fixed_latex = latex | |
| fixed = False | |
| fix_log: list[str] = [fallback_log] if fallback_log else [] | |
| if not validation.valid: | |
| fix = fix_latex(latex, validation.errors) | |
| if fix.fixed: | |
| re_validated = validate_latex(fix.latex) | |
| if re_validated.valid: | |
| fixed_latex = fix.latex | |
| fixed = True | |
| fix_log.extend(fix.log) | |
| final_validation = validate_latex(fixed_latex) | |
| return { | |
| "latex": fixed_latex, | |
| "raw": latex, | |
| "valid": final_validation.valid, | |
| "errors": [f"{e.type}: {e.detail}" for e in final_validation.errors], | |
| "intent": intent, | |
| "confidence": confidence, | |
| "latency_ms": latency, | |
| "fixed": fixed or used_fallback, | |
| "fallback": used_fallback, | |
| "fix_log": fix_log, | |
| } | |
| # ── In-memory history ─────────────────────────────────────────────────── | |
| _history: list[dict] = [] | |
| def add_history(input_text: str, input_type: str, latex: str, valid: bool, latency_ms: int) -> None: | |
| _history.append({ | |
| "input": input_text[:80], | |
| "type": input_type, | |
| "latex": latex[:120], | |
| "valid": valid, | |
| "latency_ms": latency_ms, | |
| }) | |
| if len(_history) > 20: | |
| _history.pop(0) | |
| def get_history_markdown() -> str: | |
| if not _history: | |
| return "<div class='history-empty'>No history yet</div>" | |
| lines = [] | |
| for i, item in enumerate(reversed(_history[-10:]), 1): | |
| badge = "<span class='history-badge ok'>OK</span>" if item["valid"] else "<span class='history-badge err'>ERR</span>" | |
| lines.append( | |
| f"<div class='history-item'><div class='history-row'>" | |
| f"<span class='history-num'>#{len(_history) - i + 1}</span>{badge}" | |
| f"<span class='history-type'>{item['type']}</span>" | |
| f"<span class='history-time'>{item['latency_ms']}ms</span></div>" | |
| f"<div class='history-input'>{item['input']}</div>" | |
| f"<div class='history-latex'><code>{item['latex']}</code></div></div>" | |
| ) | |
| return "\n".join(lines) | |
| # ── Loading state helper ──────────────────────────────────────────────── | |
| LOADING_HTML = """ | |
| <div class="texada-spinner-wrap"> | |
| <div class="texada-spinner"></div> | |
| <span>Model is thinking...</span> | |
| </div> | |
| """ | |
| # ── Gradio handlers ───────────────────────────────────────────────────── | |
| def convert(text: str, fmt: FormatMode) -> tuple[str, str, str, str]: | |
| if not text.strip(): | |
| return "", "", _render_preview(""), "Enter a natural language description" | |
| result = generate_latex(text, mode="generate") | |
| if result.get("error"): | |
| return "", "", _render_preview(""), f"⚠️ {result['error']}" | |
| latex = result["latex"] | |
| wrapped = wrap_for_format(latex, fmt) | |
| preview = _render_preview(wrapped) | |
| status_parts = [f"✅ Valid" if result["valid"] else f"❌ Invalid: {'; '.join(result['errors'])}"] | |
| status_parts.append(f"Intent: {result['intent']} · Confidence: {result['confidence']:.0%} · Time: {result['latency_ms']}ms") | |
| if result["fixed"]: | |
| status_parts.append(f"🔧 Auto-fixed: {'; '.join(result['fix_log'])}") | |
| add_history(text, "NL", latex, result["valid"], result["latency_ms"]) | |
| return latex, wrapped, preview, "\n".join(status_parts) | |
| def complete(text: str, fmt: FormatMode) -> tuple[str, str, str, str]: | |
| if not text.strip(): | |
| return "", "", _render_preview(""), "Enter a LaTeX fragment" | |
| result = generate_latex(text, mode="complete") | |
| if result.get("error"): | |
| return "", "", _render_preview(""), f"⚠️ {result['error']}" | |
| latex = result["latex"] | |
| wrapped = wrap_for_format(latex, fmt) | |
| preview = _render_preview(wrapped) | |
| status_parts = [f"✅ Valid" if result["valid"] else f"❌ Invalid: {'; '.join(result['errors'])}"] | |
| status_parts.append(f"Time: {result['latency_ms']}ms") | |
| if result["fixed"]: | |
| status_parts.append(f"🔧 Auto-fixed: {'; '.join(result['fix_log'])}") | |
| add_history(text, "Completion", latex, result["valid"], result["latency_ms"]) | |
| return latex, wrapped, preview, "\n".join(status_parts) | |
| def convert_image(image: Image.Image | None, fmt: FormatMode) -> tuple[str, str, str, str, str]: | |
| if image is None: | |
| return "", "", _render_preview(""), "Upload an image", get_history_markdown() | |
| result = ocr_image(image) | |
| if result.get("error"): | |
| return "", "", _render_preview(""), f"⚠️ {result['error']}", get_history_markdown() | |
| latex = result["latex"] | |
| wrapped = wrap_for_format(latex, fmt) | |
| preview = _render_preview(wrapped) | |
| status_parts = [f"✅ Valid" if result["valid"] else f"❌ Invalid: {'; '.join(result['errors'])}"] | |
| status_parts.append(f"Confidence: {result['confidence']:.0%} · Time: {result['latency_ms']}ms") | |
| if result["fixed"]: | |
| status_parts.append(f"🔧 Auto-fixed: {'; '.join(result['fix_log'])}") | |
| add_history("[image]", "OCR", latex, result["valid"], result["latency_ms"]) | |
| return latex, wrapped, preview, "\n".join(status_parts), get_history_markdown() | |
| def on_format_change(fmt: FormatMode, latex_code: str) -> str: | |
| if not latex_code.strip(): | |
| return _render_preview("") | |
| return _render_preview(wrap_for_format(latex_code, fmt)) | |
| # ── UI ────────────────────────────────────────────────────────────────── | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap'); | |
| :root { | |
| --bg: #15181f; | |
| --panel: #202631; | |
| --panel-2: #2a303c; | |
| --border: #3d4554; | |
| --text: #f7f6f2; | |
| --text-label: #e6e9ef; | |
| --text-muted: #c5cbd4; | |
| --text-subtle: #9aa4b2; | |
| --accent-blue: #7fa8c9; | |
| --accent-blue-2: #5e7f9e; | |
| --accent-yellow: #d4b56a; | |
| --accent-yellow-2: #a88a4a; | |
| --success: #7da67c; | |
| --warning: #c9a24c; | |
| --error: #c97b7b; | |
| --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| --font-code: 'JetBrains Mono', 'SF Mono', Monaco, 'Courier New', monospace; | |
| } | |
| /* Global dark reset */ | |
| body, .gradio-container { | |
| background: var(--bg) !important; | |
| color: var(--text) !important; | |
| font-family: var(--font-ui) !important; | |
| } | |
| .gradio-container { | |
| max-width: 100% !important; | |
| padding: 0 !important; | |
| } | |
| /* Header */ | |
| .texada-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 14px 28px; | |
| background: linear-gradient(90deg, #22262e 0%, #2a3039 100%); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .texada-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| } | |
| .texada-logo { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 8px; | |
| background: linear-gradient(135deg, var(--accent-blue-2), var(--accent-yellow)); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 800; | |
| color: #1c1f26; | |
| font-family: var(--font-code); | |
| font-size: 18px; | |
| box-shadow: 0 2px 8px rgba(201, 169, 89, 0.15); | |
| } | |
| .texada-title, | |
| .texada-header .texada-title { | |
| font-size: 20px !important; | |
| font-weight: 800 !important; | |
| color: #ffffff !important; | |
| margin: 0 !important; | |
| letter-spacing: -0.02em !important; | |
| text-shadow: 0 1px 2px rgba(0,0,0,0.35) !important; | |
| } | |
| .texada-subtitle, | |
| .texada-header .texada-subtitle { | |
| font-size: 13px !important; | |
| color: #d8dde4 !important; | |
| margin: 0 !important; | |
| font-weight: 600 !important; | |
| } | |
| .texada-badges { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .badge { | |
| padding: 5px 12px; | |
| border-radius: 14px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| border: 1px solid var(--border); | |
| background: var(--panel-2); | |
| color: var(--text-label); | |
| } | |
| .badge.accent { | |
| background: rgba(212, 181, 106, 0.14); | |
| color: #f0e6cc; | |
| border-color: rgba(212, 181, 106, 0.45); | |
| } | |
| .badge.blue { | |
| background: rgba(127, 168, 201, 0.14); | |
| color: #d6e6f2; | |
| border-color: rgba(127, 168, 201, 0.45); | |
| } | |
| /* Main layout */ | |
| .texada-main { | |
| padding: 18px 28px 0; | |
| } | |
| /* Tabs -> VSCode-style tab bar */ | |
| .texada-tabs .tab-nav { | |
| background: var(--panel) !important; | |
| border-bottom: 1px solid var(--border) !important; | |
| padding: 0 14px !important; | |
| } | |
| .texada-tabs .tab-nav button { | |
| background: transparent !important; | |
| color: #f7f6f2 !important; | |
| border: none !important; | |
| border-bottom: 2px solid transparent !important; | |
| padding: 12px 18px !important; | |
| font-family: var(--font-ui) !important; | |
| font-size: 14px !important; | |
| font-weight: 700 !important; | |
| } | |
| .texada-tabs .tab-nav button:hover { | |
| color: #ffffff !important; | |
| background: rgba(255,255,255,0.06) !important; | |
| } | |
| .texada-tabs .tab-nav button.selected { | |
| color: #f7f0df !important; | |
| border-bottom-color: var(--accent-yellow) !important; | |
| background: var(--panel-2) !important; | |
| } | |
| /* Panels & labels */ | |
| .texada-panel { | |
| background: var(--panel) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px !important; | |
| padding: 18px !important; | |
| } | |
| .texada-panel .label-wrap, | |
| .texada-panel .label-wrap > span, | |
| .texada-panel .label-wrap > label { | |
| background: transparent !important; | |
| color: var(--text-label) !important; | |
| font-size: 13px !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 0.07em !important; | |
| font-weight: 800 !important; | |
| } | |
| .texada-no-label .label-wrap, | |
| .texada-no-label .label-wrap > span, | |
| .texada-no-label .label-wrap > label { | |
| display: none !important; | |
| } | |
| /* Inputs / textareas */ | |
| .texada-input textarea, | |
| .texada-output textarea, | |
| .texada-status textarea { | |
| background: #181c23 !important; | |
| color: #ffffff !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 8px !important; | |
| font-family: var(--font-code) !important; | |
| font-size: 15px !important; | |
| line-height: 1.65 !important; | |
| padding: 14px !important; | |
| font-weight: 500 !important; | |
| } | |
| .texada-input textarea::placeholder, | |
| .texada-output textarea::placeholder { | |
| color: #7b8697 !important; | |
| font-weight: 400 !important; | |
| } | |
| .texada-input textarea:focus, | |
| .texada-output textarea:focus { | |
| border-color: var(--accent-blue) !important; | |
| box-shadow: 0 0 0 3px rgba(127, 168, 201, 0.22) !important; | |
| } | |
| /* Output code box */ | |
| .texada-output textarea { | |
| min-height: 120px; | |
| } | |
| .texada-status textarea { | |
| color: var(--text-label) !important; | |
| background: #181c23 !important; | |
| } | |
| /* Preview panel */ | |
| .texada-preview { | |
| background: #f7f5f0 !important; | |
| color: #1c1f26 !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 8px !important; | |
| min-height: 220px; | |
| padding: 24px !important; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: auto; | |
| } | |
| .texada-preview * { | |
| color: #1c1f26 !important; | |
| } | |
| .texada-preview-empty { | |
| color: #5c6574 !important; | |
| font-size: 14px; | |
| font-weight: 600; | |
| } | |
| /* Buttons */ | |
| .texada-btn { | |
| background: linear-gradient(180deg, var(--accent-blue), var(--accent-blue-2)) !important; | |
| color: #f0eee9 !important; | |
| border: 1px solid rgba(122, 154, 184, 0.45) !important; | |
| border-radius: 8px !important; | |
| padding: 10px 20px !important; | |
| font-weight: 700 !important; | |
| font-size: 14px !important; | |
| box-shadow: 0 1px 0 rgba(255,255,255,0.05) inset !important; | |
| } | |
| .texada-btn:hover { | |
| filter: brightness(1.08); | |
| } | |
| .texada-btn-secondary { | |
| background: var(--panel-2) !important; | |
| color: var(--text) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 8px !important; | |
| padding: 8px 16px !important; | |
| font-weight: 600 !important; | |
| font-size: 13px !important; | |
| } | |
| /* Radio / format selector */ | |
| .texada-format .wrap { | |
| gap: 10px !important; | |
| } | |
| .texada-format label, | |
| .texada-format .radio-label, | |
| .texada-format .item label, | |
| .texada-format .item span { | |
| background: var(--panel-2) !important; | |
| border: 1px solid var(--border) !important; | |
| color: #e6e9ef !important; | |
| border-radius: 8px !important; | |
| padding: 8px 14px !important; | |
| font-size: 13px !important; | |
| font-weight: 700 !important; | |
| } | |
| .texada-format label.selected, | |
| .texada-format .selected .radio-label, | |
| .texada-format .selected span { | |
| background: rgba(212, 181, 106, 0.22) !important; | |
| color: #f7f0df !important; | |
| border-color: rgba(212, 181, 106, 0.6) !important; | |
| } | |
| /* Examples */ | |
| .texada-examples { | |
| margin-top: 10px !important; | |
| } | |
| .texada-examples .examples-table { | |
| background: var(--panel-2) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 8px !important; | |
| } | |
| .texada-examples .examples-table td { | |
| color: var(--text-label) !important; | |
| font-family: var(--font-code) !important; | |
| font-size: 13px !important; | |
| padding: 10px 14px !important; | |
| border-bottom: 1px solid var(--border) !important; | |
| font-weight: 500 !important; | |
| } | |
| .texada-examples .examples-table tr:last-child td { | |
| border-bottom: none !important; | |
| } | |
| .texada-examples .examples-table td:hover { | |
| color: #f0e6cc !important; | |
| background: rgba(212, 181, 106, 0.12) !important; | |
| } | |
| /* Section titles */ | |
| .texada-section-title { | |
| font-size: 13px; | |
| font-weight: 800; | |
| color: var(--text-label); | |
| text-transform: uppercase; | |
| letter-spacing: 0.07em; | |
| margin: 0 0 14px 0; | |
| } | |
| /* Status bar */ | |
| .texada-statusbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 28px; | |
| background: #252b35; | |
| color: #e6e9ef !important; | |
| font-size: 13px; | |
| font-family: var(--font-ui); | |
| margin-top: 18px; | |
| border-top: 1px solid var(--border); | |
| font-weight: 700; | |
| } | |
| .texada-statusbar span { | |
| color: #e6e9ef !important; | |
| } | |
| .texada-statusbar-left { | |
| display: flex; | |
| gap: 22px; | |
| align-items: center; | |
| } | |
| .texada-statusbar-right { | |
| display: flex; | |
| gap: 22px; | |
| align-items: center; | |
| } | |
| .status-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--success); | |
| display: inline-block; | |
| margin-right: 8px; | |
| } | |
| .status-dot.warn { | |
| background: var(--warning); | |
| } | |
| /* History sidebar */ | |
| .texada-history { | |
| background: var(--panel) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px !important; | |
| padding: 18px !important; | |
| color: var(--text-label) !important; | |
| max-height: 720px; | |
| overflow-y: auto; | |
| } | |
| .texada-history h4 { | |
| margin: 0 0 14px 0 !important; | |
| font-size: 13px !important; | |
| color: #f7f0df !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 0.07em !important; | |
| font-weight: 800 !important; | |
| } | |
| .history-empty { | |
| color: #c5cbd4 !important; | |
| font-size: 13px !important; | |
| padding: 14px 0 !important; | |
| font-weight: 600 !important; | |
| } | |
| .history-item { | |
| border-bottom: 1px solid var(--border); | |
| padding: 12px 0; | |
| font-size: 13px; | |
| } | |
| .history-item:last-child { | |
| border-bottom: none; | |
| } | |
| .history-row { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| margin-bottom: 6px; | |
| } | |
| .history-num { | |
| color: #9aa4b2 !important; | |
| font-family: var(--font-code); | |
| min-width: 32px; | |
| font-weight: 700; | |
| } | |
| .history-type { | |
| color: #d6e6f2 !important; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| font-size: 10px; | |
| } | |
| .history-time { | |
| margin-left: auto; | |
| color: #9aa4b2 !important; | |
| font-family: var(--font-code); | |
| font-weight: 700; | |
| } | |
| .history-badge { | |
| padding: 2px 7px; | |
| border-radius: 5px; | |
| font-size: 10px; | |
| font-weight: 800; | |
| } | |
| .history-badge.ok { | |
| background: rgba(125, 166, 124, 0.22); | |
| color: #c8e4c7 !important; | |
| } | |
| .history-badge.err { | |
| background: rgba(201, 123, 123, 0.22); | |
| color: #f0c5c5 !important; | |
| } | |
| .history-input { | |
| color: #f7f6f2 !important; | |
| margin-bottom: 6px; | |
| word-break: break-all; | |
| font-weight: 600; | |
| } | |
| .history-latex code { | |
| background: var(--bg); | |
| padding: 4px 7px; | |
| border-radius: 5px; | |
| color: #c5cbd4 !important; | |
| font-family: var(--font-code); | |
| font-size: 11px; | |
| font-weight: 600; | |
| } | |
| /* Spinner / loading */ | |
| .texada-spinner-wrap { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| padding: 40px 20px; | |
| color: var(--text-label); | |
| font-size: 14px; | |
| font-weight: 700; | |
| } | |
| .texada-spinner { | |
| width: 24px; | |
| height: 24px; | |
| border: 3px solid var(--border); | |
| border-top-color: var(--accent-yellow); | |
| border-radius: 50%; | |
| animation: texada-spin 0.8s linear infinite; | |
| } | |
| @keyframes texada-spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Hide unwanted labels */ | |
| .texada-no-label > .label-wrap { | |
| display: none !important; | |
| } | |
| """ | |
| def _render_preview(wrapped: str) -> str: | |
| if not wrapped.strip(): | |
| return '<div class="texada-preview-empty">Formula preview will appear here</div>' | |
| return wrapped | |
| with gr.Blocks(title="TeXada — Math Formula Agent") as demo: | |
| # ── Header ── | |
| with gr.Row(elem_classes=["texada-header"]): | |
| with gr.Column(scale=0, min_width=300): | |
| gr.HTML( | |
| """ | |
| <div class="texada-brand"> | |
| <div class="texada-logo">T</div> | |
| <div> | |
| <div class="texada-title">TeXada</div> | |
| <div class="texada-subtitle">Math Formula Agent · Backyard AI</div> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| with gr.Column(scale=1): | |
| gr.HTML( | |
| f""" | |
| <div class="texada-badges"> | |
| <span class="badge accent">MiniCPM5-1B · {PARAM_COUNTS_GB.get(MODEL_NAME, '≤ 32')}B</span> | |
| <span class="badge blue">Gradio 6.x</span> | |
| <span class="badge">≤ 32B Compliant</span> | |
| </div> | |
| """ | |
| ) | |
| # ── Main workspace ── | |
| with gr.Column(elem_classes=["texada-main"]): | |
| with gr.Row(): | |
| # ── Left: tabs / editor ── | |
| with gr.Column(scale=3): | |
| with gr.Tabs(elem_classes=["texada-tabs"]): | |
| # ── Tab 1: NL → LaTeX ── | |
| with gr.TabItem("Natural Language → LaTeX"): | |
| with gr.Row(): | |
| # Editor column | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| nl_input = gr.Textbox( | |
| label="Describe your formula", | |
| placeholder="e.g. double integral of f(x,y) over region D", | |
| lines=5, | |
| elem_classes=["texada-input", "texada-no-label"], | |
| ) | |
| with gr.Row(): | |
| fmt_selector = gr.Radio( | |
| choices=[("Display", "display"), ("Inline", "inline"), ("Equation", "equation")], | |
| value="display", | |
| label="Output format", | |
| elem_classes=["texada-format"], | |
| ) | |
| nl_btn = gr.Button("Generate LaTeX", elem_classes=["texada-btn"], scale=0) | |
| nl_examples = gr.Examples( | |
| label="Quick examples", | |
| examples=[ | |
| ["double integral of f(x,y) over region D"], | |
| ["x squared plus y squared"], | |
| ["sum of x_i from i=1 to n"], | |
| ["limit of sin(x)/x as x approaches 0"], | |
| ["X follows normal distribution N(mu, sigma^2)"], | |
| ], | |
| inputs=nl_input, | |
| ) | |
| nl_latex = gr.Textbox( | |
| label="LaTeX code", | |
| placeholder="Generated LaTeX code...", | |
| lines=4, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| nl_wrapped = gr.Textbox( | |
| label="With delimiters", | |
| placeholder="LaTeX with delimiters...", | |
| lines=2, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| # Preview column | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| gr.Markdown("<div class='texada-section-title'>Rendered Preview</div>") | |
| nl_preview = gr.Markdown( | |
| elem_classes=["texada-preview"], | |
| latex_delimiters=[ | |
| {"left": "$$", "right": "$$", "display": True}, | |
| {"left": "$", "right": "$", "display": False}, | |
| ], | |
| ) | |
| nl_status = gr.Textbox( | |
| label="Status", | |
| placeholder="Waiting for input...", | |
| lines=3, | |
| interactive=False, | |
| elem_classes=["texada-status", "texada-no-label"], | |
| ) | |
| # ── Tab 2: LaTeX Completion ── | |
| with gr.TabItem("LaTeX Completion"): | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| comp_input = gr.Textbox( | |
| label="LaTeX fragment", | |
| placeholder="e.g. \\sum_{i=1}^{", | |
| lines=5, | |
| elem_classes=["texada-input", "texada-no-label"], | |
| ) | |
| with gr.Row(): | |
| comp_fmt = gr.Radio( | |
| choices=[("Display", "display"), ("Inline", "inline"), ("Equation", "equation")], | |
| value="display", | |
| label="Output format", | |
| elem_classes=["texada-format"], | |
| ) | |
| comp_btn = gr.Button("Complete", elem_classes=["texada-btn"], scale=0) | |
| comp_examples = gr.Examples( | |
| label="Quick examples", | |
| examples=[ | |
| ["\\sum_{i=1}^{"], | |
| ["\\frac{"], | |
| ["\\int"], | |
| ["\\sqrt{"], | |
| ], | |
| inputs=comp_input, | |
| ) | |
| comp_latex = gr.Textbox( | |
| label="LaTeX code", | |
| lines=4, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| comp_wrapped = gr.Textbox( | |
| label="With delimiters", | |
| lines=2, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| gr.Markdown("<div class='texada-section-title'>Rendered Preview</div>") | |
| comp_preview = gr.Markdown( | |
| elem_classes=["texada-preview"], | |
| latex_delimiters=[ | |
| {"left": "$$", "right": "$$", "display": True}, | |
| {"left": "$", "right": "$", "display": False}, | |
| ], | |
| ) | |
| comp_status = gr.Textbox( | |
| label="Status", | |
| lines=3, | |
| interactive=False, | |
| elem_classes=["texada-status", "texada-no-label"], | |
| ) | |
| # ── Tab 3: Image OCR ── | |
| with gr.TabItem("Image OCR"): | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| ocr_image_input = gr.Image( | |
| label="Upload formula image", | |
| type="pil", | |
| elem_classes=["texada-input"], | |
| ) | |
| with gr.Row(): | |
| ocr_fmt = gr.Radio( | |
| choices=[("Display", "display"), ("Inline", "inline"), ("Equation", "equation")], | |
| value="display", | |
| label="Output format", | |
| elem_classes=["texada-format"], | |
| ) | |
| ocr_btn = gr.Button("Recognize Formula", elem_classes=["texada-btn"], scale=0) | |
| ocr_latex = gr.Textbox( | |
| label="LaTeX code", | |
| lines=4, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| ocr_wrapped = gr.Textbox( | |
| label="With delimiters", | |
| lines=2, | |
| interactive=True, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| gr.Markdown("<div class='texada-section-title'>Rendered Preview</div>") | |
| ocr_preview = gr.Markdown( | |
| elem_classes=["texada-preview"], | |
| latex_delimiters=[ | |
| {"left": "$$", "right": "$$", "display": True}, | |
| {"left": "$", "right": "$", "display": False}, | |
| ], | |
| ) | |
| ocr_status = gr.Textbox( | |
| label="Status", | |
| lines=3, | |
| interactive=False, | |
| elem_classes=["texada-status", "texada-no-label"], | |
| ) | |
| # ── Tab 4: Validator ── | |
| with gr.TabItem("LaTeX Validator"): | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| val_input = gr.Textbox( | |
| label="Input LaTeX", | |
| placeholder="e.g. \\frac{a}{b+c", | |
| lines=8, | |
| elem_classes=["texada-input", "texada-no-label"], | |
| ) | |
| val_btn = gr.Button("Run Validation", elem_classes=["texada-btn"]) | |
| with gr.Column(scale=1, elem_classes=["texada-panel"]): | |
| gr.Markdown("<div class='texada-section-title'>Validation Result</div>") | |
| val_output = gr.Textbox( | |
| placeholder="Validation result will appear here...", | |
| lines=12, | |
| interactive=False, | |
| elem_classes=["texada-output", "texada-no-label"], | |
| ) | |
| # ── Right: history sidebar ── | |
| with gr.Column(scale=1, elem_classes=["texada-history"]): | |
| gr.Markdown("<h4>🕘 History</h4>") | |
| history_panel = gr.HTML( | |
| value=get_history_markdown(), | |
| elem_classes=["texada-history-list"], | |
| ) | |
| # ── Status bar ── | |
| gr.HTML( | |
| f""" | |
| <div class="texada-statusbar"> | |
| <div class="texada-statusbar-left"> | |
| <span><span class="status-dot"></span>Ready</span> | |
| <span>Text: {MODEL_NAME} · {PARAM_COUNTS_GB.get(MODEL_NAME, '≤ 32')}B</span> | |
| <span>Vision: {VISION_MODEL_NAME} · {PARAM_COUNTS_GB.get(VISION_MODEL_NAME, '≤ 32')}B</span> | |
| </div> | |
| <div class="texada-statusbar-right"> | |
| <span>Built on Gradio</span> | |
| <span>Backyard AI · Build Small Hackathon</span> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| # ── Events ── | |
| nl_btn.click( | |
| fn=convert, | |
| inputs=[nl_input, fmt_selector], | |
| outputs=[nl_latex, nl_wrapped, nl_preview, nl_status], | |
| ).then(fn=get_history_markdown, outputs=history_panel) | |
| fmt_selector.change(fn=on_format_change, inputs=[fmt_selector, nl_latex], outputs=nl_preview) | |
| comp_btn.click( | |
| fn=complete, | |
| inputs=[comp_input, comp_fmt], | |
| outputs=[comp_latex, comp_wrapped, comp_preview, comp_status], | |
| ).then(fn=get_history_markdown, outputs=history_panel) | |
| comp_fmt.change(fn=on_format_change, inputs=[comp_fmt, comp_latex], outputs=comp_preview) | |
| ocr_btn.click( | |
| fn=convert_image, | |
| inputs=[ocr_image_input, ocr_fmt], | |
| outputs=[ocr_latex, ocr_wrapped, ocr_preview, ocr_status, history_panel], | |
| ) | |
| ocr_fmt.change(fn=on_format_change, inputs=[ocr_fmt, ocr_latex], outputs=ocr_preview) | |
| def _validate(text: str) -> str: | |
| res = validate_latex(text) | |
| if res.valid: | |
| return "✅ LaTeX syntax is valid" | |
| return "❌ Issues found:\n" + "\n".join(f"- {e.type}: {e.detail}" for e in res.errors) | |
| val_btn.click(fn=_validate, inputs=val_input, outputs=val_output) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=1).launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| css=CSS, | |
| ) | |