Spaces:
Sleeping
Sleeping
LeonidNN
Three independent solving methods with answer agreement, figures, new UI and benchmark
74ca799 Download web/render.py from leonid-nn/hybrid-solver: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/spaces/leonid-nn/hybrid-solver/resolve/main/web/render.py
- Command line
-
hf download hf://spaces/leonid-nn/hybrid-solver/web/render.py
-
curl -L -o render.py https://huggingface.co/spaces/leonid-nn/hybrid-solver/resolve/main/web/render.py
12.5 kB
| """ | |
| HTML-блоки интерфейса: шапка, ход решения, итог, чертёж, подробности проверки. | |
| """ | |
| import html | |
| import re | |
| from dataclasses import dataclass, field | |
| from app.analysis import Analysis | |
| from app.consensus import CONFIRMED, CONFLICT, PROOF, Verdict | |
| from app.methods import METHODS, MethodResult | |
| ICONS = { | |
| "analysis": '<circle cx="11" cy="11" r="6"/><path d="M20 20l-4.5-4.5"/>', | |
| "reasoning": '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>', | |
| "symbolic": '<path d="M17 5H7l6 7-6 7h10"/>', | |
| "numeric": '<rect x="4" y="4" width="16" height="16" rx="3"/><circle cx="9" cy="9" r="1.2"/>' | |
| '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/><circle cx="15" cy="15" r="1.2"/>', | |
| "checking": '<path d="M12 4v16"/><path d="M5 8h14"/><path d="M5 8l-2.5 6h5z"/><path d="M19 8l-2.5 6h5z"/>', | |
| "ok": '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.8 2.8L16.5 9"/>', | |
| "warn": '<path d="M12 3.5l9 16H3z"/><path d="M12 10v4.5"/><path d="M12 17.2v.3"/>', | |
| "info": '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><path d="M12 7.6v.3"/>', | |
| } | |
| ANSWER_TYPE_TITLES = { | |
| "number": "число", | |
| "numbers": "несколько чисел", | |
| "expression": "выражение", | |
| "tuple": "пара или набор", | |
| "interval": "промежуток", | |
| "text": "текст", | |
| "proof": "доказательство", | |
| } | |
| MATHML_TAGS = { | |
| "math", "mrow", "mi", "mn", "mo", "msqrt", "mroot", "mfrac", "msup", "msub", "msubsup", | |
| "mtext", "mfenced", "mspace", "mover", "munder", "munderover", "mstyle", "mpadded", | |
| "mtable", "mtr", "mtd", | |
| } | |
| MATHML_ATTRS = { | |
| "separator", "stretchy", "fence", "mathvariant", "open", "close", "separators", | |
| "linethickness", "form", "lspace", "rspace", "accent", "columnalign", "display", | |
| } | |
| TAG_RE = re.compile(r"<(/?)([a-z]+)((?:\s+[a-z-]+=\"[^\"<>]*\")*)\s*(/?)>") | |
| ATTR_RE = re.compile(r"([a-z-]+)=\"[^\"<>]*\"") | |
| TEXT_RE = re.compile(r"^(?:[^<>&]|&[a-zA-Z]+;|&#x?[0-9a-fA-F]+;)*$") | |
| class Progress: | |
| analysis: Analysis | None = None | |
| running: set[str] = field(default_factory=set) | |
| results: dict[str, MethodResult] = field(default_factory=dict) | |
| checking: bool = False | |
| def apply(self, kind: str, data) -> None: | |
| if kind == "analysis": | |
| self.analysis = data | |
| elif kind == "method_started": | |
| self.running.add(data) | |
| elif kind == "method_done": | |
| self.running.discard(data.key) | |
| self.results[data.key] = data | |
| elif kind == "checking": | |
| self.checking = True | |
| def icon(name: str, cls: str = "icon") -> str: | |
| return ( | |
| f'<svg class="{cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' | |
| f'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{ICONS[name]}</svg>' | |
| ) | |
| def safe_mathml(markup: str, display: str = "inline") -> str | None: | |
| """Пропускает только MathML из разрешённых тегов и атрибутов.""" | |
| if not markup: | |
| return None | |
| position = 0 | |
| for match in TAG_RE.finditer(markup): | |
| if not TEXT_RE.match(markup[position:match.start()]): | |
| return None | |
| if match.group(2) not in MATHML_TAGS: | |
| return None | |
| if any(attr not in MATHML_ATTRS for attr in ATTR_RE.findall(match.group(3))): | |
| return None | |
| position = match.end() | |
| if not TEXT_RE.match(markup[position:]): | |
| return None | |
| return f'<math display="{display}">{markup}</math>' | |
| def pretty(answer: str) -> str: | |
| """Читаемый вид машинного ответа, пока он не разобран sympy.""" | |
| text = answer.strip() | |
| for src, dst in (("**", "^"), ("sqrt", "√"), ("*", "·"), ("pi", "π"), ("oo", "∞")): | |
| text = text.replace(src, dst) | |
| return html.escape(text) | |
| def _short(text: str, limit: int = 160) -> str: | |
| line = text.strip().splitlines()[-1] if text.strip() else "" | |
| return html.escape(line if len(line) <= limit else line[: limit - 1] + "…") | |
| def header_html() -> str: | |
| steps = "".join( | |
| f'<div class="hero-step">{icon(key)}<div><b>{info.title}</b><span>{info.subtitle}</span></div></div>' | |
| for key, info in METHODS.items() | |
| ) | |
| return f""" | |
| <div class="hero"> | |
| <div class="hero-badge">LLM + SymPy + численная проверка</div> | |
| <h1>Hybrid <span>Math Solver</span></h1> | |
| <p>Решает задачу тремя независимыми способами и выдаёт ответ, только если они сошлись. | |
| Если методы расходятся, решатель честно говорит, что надёжного ответа нет.</p> | |
| <div class="hero-steps">{steps}</div> | |
| </div>""" | |
| def _stage(name: str, title: str, state: str, body: str, meta: str = "") -> str: | |
| labels = {"wait": "ожидает", "run": "в работе", "done": "готово", "fail": "нет ответа", | |
| "skip": "не нужен", "ok": "совпал", "bad": "расходится"} | |
| spinner = '<span class="spinner"></span>' if state == "run" else "" | |
| return f""" | |
| <div class="stage stage-{state}"> | |
| <div class="stage-head">{icon(name)}<span class="stage-title">{title}</span> | |
| <span class="state state-{state}">{spinner}{labels[state]}</span></div> | |
| <div class="stage-body">{body}</div> | |
| {f'<div class="stage-meta">{meta}</div>' if meta else ''} | |
| </div>""" | |
| def _method_stage(key: str, progress: Progress, verdict: Verdict | None) -> str: | |
| info = METHODS[key] | |
| result = progress.results.get(key) | |
| if progress.analysis is not None and progress.analysis.is_proof and key != "reasoning": | |
| return _stage(key, info.title, "skip", '<span class="muted">для доказательства не применяется</span>') | |
| if result is None: | |
| state = "run" if key in progress.running else "wait" | |
| return _stage(key, info.title, state, f'<span class="muted">{info.subtitle}</span>') | |
| if not result.ok: | |
| return _stage(key, info.title, "fail", f'<span class="error">{_short(result.error) or "нет ответа"}</span>', | |
| f"{result.seconds:.1f} с") | |
| answer_html = f'<span class="answer-raw">{pretty(result.answer)}</span>' | |
| state = "done" | |
| if verdict is not None and key in verdict.parsed: | |
| mathml = safe_mathml(verdict.parsed[key].get("mathml", "")) | |
| if mathml: | |
| answer_html = f'<span class="answer-math">{mathml}</span>' | |
| if verdict.status == CONFIRMED: | |
| state = "ok" if key in verdict.agreeing else "done" | |
| elif verdict.status == CONFLICT: | |
| state = "bad" | |
| meta = f"{result.seconds:.1f} с" | |
| if result.attempts > 1: | |
| meta += f" · код исправлен с {result.attempts}-й попытки" | |
| return _stage(key, info.title, state, answer_html, meta) | |
| def pipeline_html(progress: Progress, verdict: Verdict | None = None) -> str: | |
| analysis = progress.analysis | |
| if analysis is None: | |
| first = _stage("analysis", "Разбор условия", "run", '<span class="muted">тема, вид ответа, нужен ли чертёж</span>') | |
| else: | |
| chips = f'<span class="chip">{analysis.topic_title}</span>' | |
| chips += f'<span class="chip">ответ: {ANSWER_TYPE_TITLES.get(analysis.answer_type, analysis.answer_type)}</span>' | |
| if analysis.figure != "none": | |
| chips += '<span class="chip">с чертежом</span>' | |
| first = _stage("analysis", "Разбор условия", "done", | |
| f'<div class="chips">{chips}</div><div class="muted small">{html.escape(analysis.answer_format)}</div>') | |
| methods = "".join(_method_stage(key, progress, verdict) for key in METHODS) | |
| if verdict is not None: | |
| state = {CONFIRMED: "ok", CONFLICT: "bad"}.get(verdict.status, "fail" if verdict.status != PROOF else "skip") | |
| last = _stage("checking", "Сверка ответов", state, f'<span class="muted">{html.escape(verdict.title)}</span>') | |
| elif progress.checking: | |
| last = _stage("checking", "Сверка ответов", "run", '<span class="muted">сравниваем ответы в SymPy</span>') | |
| else: | |
| last = _stage("checking", "Сверка ответов", "wait", '<span class="muted">ответ выдаётся, только если методы совпали</span>') | |
| return f""" | |
| <div class="pipeline"> | |
| <div class="pipe-col">{first}</div> | |
| <div class="pipe-arrow"></div> | |
| <div class="pipe-col pipe-methods">{methods}</div> | |
| <div class="pipe-arrow"></div> | |
| <div class="pipe-col">{last}</div> | |
| </div>""" | |
| def verdict_html(verdict: Verdict, analysis: Analysis, seconds: float, calls: int, tokens: int, | |
| cost: float | None) -> str: | |
| kind = {CONFIRMED: "ok", PROOF: "info"}.get(verdict.status, "warn") | |
| answer = "" | |
| if verdict.confirmed: | |
| mathml = safe_mathml(verdict.answer_mathml, "block") | |
| formula = mathml or f'<span class="answer-raw">{pretty(verdict.answer_text)}</span>' | |
| approx = f'<span class="approx">≈ {html.escape(verdict.answer_numeric)}</span>' if verdict.answer_numeric else "" | |
| answer = f'<div class="verdict-answer"><span class="label">Ответ</span>{formula}{approx}</div>' | |
| chips = [analysis.topic_title, f"{seconds:.0f} с", f"{calls} запросов к модели", f"{tokens:,} токенов".replace(",", " ")] | |
| if cost is not None: | |
| chips.append(f"{cost:.2f} ₽" if cost >= 0.01 else "< 0.01 ₽") | |
| chips_html = "".join(f'<span class="chip">{html.escape(c)}</span>' for c in chips) | |
| return f""" | |
| <div class="verdict verdict-{kind}"> | |
| <div class="verdict-head">{icon(kind, "verdict-icon")} | |
| <div><div class="verdict-title">{html.escape(verdict.title)}</div> | |
| <div class="verdict-msg">{html.escape(verdict.message)}</div></div> | |
| </div> | |
| {answer} | |
| <div class="chips">{chips_html}</div> | |
| </div>""" | |
| def figure_html(svg: str) -> str: | |
| return f""" | |
| <div class="figure-card"> | |
| <div class="figure-title">Чертёж</div> | |
| {svg} | |
| <div class="figure-note">Построен по координатам, которые вычислил код точного решения</div> | |
| </div>""" | |
| def details_html(verdict: Verdict, analysis: Analysis, results: dict[str, MethodResult]) -> str: | |
| rows = [] | |
| for key, result in results.items(): | |
| parsed = verdict.parsed.get(key, {}) | |
| answer = safe_mathml(parsed.get("mathml", "")) or (pretty(result.answer) if result.answer else "—") | |
| status = "ответ получен" if result.ok else html.escape(_short(result.error, 90) or "нет ответа") | |
| rows.append( | |
| f"<tr><td><b>{METHODS[key].title}</b></td><td>{answer}</td><td>{status}</td>" | |
| f"<td>{result.seconds:.1f} с</td><td>{result.attempts}</td>" | |
| f"<td>{result.usage.prompt_tokens + result.usage.completion_tokens:,}</td></tr>".replace(",", " ") | |
| ) | |
| pairs = [] | |
| for pair, equal in verdict.agreement.items(): | |
| a, b = pair.split(":") | |
| mark = '<span class="yes">совпадают</span>' if equal else '<span class="no">различаются</span>' | |
| pairs.append(f"<li>{METHODS[a].title} и {METHODS[b].title.lower()}: {mark}</li>") | |
| pairs_html = f"<ul class='pairs'>{''.join(pairs)}</ul>" if pairs else "" | |
| tolerance = ("Точные ответы сравниваются символьно или с относительной погрешностью 10<sup>−9</sup>, " | |
| "с численной проверкой — с погрешностью 1%.") | |
| return f""" | |
| <div class="details"> | |
| <h3>Разбор условия</h3> | |
| <p>{html.escape(analysis.summary) or '—'}</p> | |
| <p class="muted">Тема: {analysis.topic_title}. Вид ответа: {html.escape(analysis.answer_format)}.</p> | |
| <h3>Ответы методов</h3> | |
| <table><thead><tr><th>Метод</th><th>Ответ</th><th>Статус</th><th>Время</th><th>Попыток</th><th>Токенов</th></tr></thead> | |
| <tbody>{''.join(rows)}</tbody></table> | |
| <h3>Сверка</h3> | |
| {pairs_html} | |
| <p class="muted">{tolerance} Ответ выдаётся, если ответили хотя бы два метода, все ответы совпали и среди них есть точный.</p> | |
| </div>""" | |