"""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: $$$$""" 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 ─────────────────────────────────────────────────────────────── @dataclass class CheckResult: ok: bool type: str = "" detail: str = "" @dataclass class ValidationResult: valid: bool errors: list[CheckResult] = field(default_factory=list) @dataclass 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 "
No history yet
" lines = [] for i, item in enumerate(reversed(_history[-10:]), 1): badge = "OK" if item["valid"] else "ERR" lines.append( f"
" f"#{len(_history) - i + 1}{badge}" f"{item['type']}" f"{item['latency_ms']}ms
" f"
{item['input']}
" f"
{item['latex']}
" ) return "\n".join(lines) # ── Loading state helper ──────────────────────────────────────────────── LOADING_HTML = """
Model is thinking...
""" # ── 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 '
Formula preview will appear here
' 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( """
TeXada
Math Formula Agent · Backyard AI
""" ) with gr.Column(scale=1): gr.HTML( f"""
MiniCPM5-1B · {PARAM_COUNTS_GB.get(MODEL_NAME, '≤ 32')}B Gradio 6.x ≤ 32B Compliant
""" ) # ── 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("
Rendered Preview
") 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("
Rendered Preview
") 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("
Rendered Preview
") 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("
Validation Result
") 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("

🕘 History

") history_panel = gr.HTML( value=get_history_markdown(), elem_classes=["texada-history-list"], ) # ── Status bar ── gr.HTML( f"""
Ready Text: {MODEL_NAME} · {PARAM_COUNTS_GB.get(MODEL_NAME, '≤ 32')}B Vision: {VISION_MODEL_NAME} · {PARAM_COUNTS_GB.get(VISION_MODEL_NAME, '≤ 32')}B
Built on Gradio Backyard AI · Build Small Hackathon
""" ) # ── 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, )