texada / app.py
Cacinie's picture
ui: remove tab emojis, brighten unselected tab text
cfcf0a3
Raw
History Blame Contribute Delete
53.7 kB
"""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 ───────────────────────────────────────────────────────────────
@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 "<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,
)