hybrid-solver / web /render.py
LeonidNN
Three independent solving methods with answer agreement, figures, new UI and benchmark
74ca799
Raw History Blame Contribute Delete
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]+;)*$")
@dataclass
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>"""