"""
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": '',
"reasoning": '',
"symbolic": '',
"numeric": ''
'',
"checking": '',
"ok": '',
"warn": '',
"info": '',
}
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]+;|?[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''
)
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''
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'
{icon(key)}
{info.title}{info.subtitle}
'
for key, info in METHODS.items()
)
return f"""
LLM + SymPy + численная проверка
Hybrid Math Solver
Решает задачу тремя независимыми способами и выдаёт ответ, только если они сошлись.
Если методы расходятся, решатель честно говорит, что надёжного ответа нет.
"""
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", 'для доказательства не применяется')
if result is None:
state = "run" if key in progress.running else "wait"
return _stage(key, info.title, state, f'{info.subtitle}')
if not result.ok:
return _stage(key, info.title, "fail", f'{_short(result.error) or "нет ответа"}',
f"{result.seconds:.1f} с")
answer_html = f'{pretty(result.answer)}'
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'{mathml}'
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", 'тема, вид ответа, нужен ли чертёж')
else:
chips = f'{analysis.topic_title}'
chips += f'ответ: {ANSWER_TYPE_TITLES.get(analysis.answer_type, analysis.answer_type)}'
if analysis.figure != "none":
chips += 'с чертежом'
first = _stage("analysis", "Разбор условия", "done",
f'
{chips}
{html.escape(analysis.answer_format)}
')
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'{html.escape(verdict.title)}')
elif progress.checking:
last = _stage("checking", "Сверка ответов", "run", 'сравниваем ответы в SymPy')
else:
last = _stage("checking", "Сверка ответов", "wait", 'ответ выдаётся, только если методы совпали')
return f"""
{first}
{methods}
{last}
"""
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'{pretty(verdict.answer_text)}'
approx = f'≈ {html.escape(verdict.answer_numeric)}' if verdict.answer_numeric else ""
answer = f'
Ответ{formula}{approx}
'
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'{html.escape(c)}' for c in chips)
return f"""
{icon(kind, "verdict-icon")}
{html.escape(verdict.title)}
{html.escape(verdict.message)}
{answer}
{chips_html}
"""
def figure_html(svg: str) -> str:
return f"""
Чертёж
{svg}
Построен по координатам, которые вычислил код точного решения
"""
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"
".replace(",", " ")
)
pairs = []
for pair, equal in verdict.agreement.items():
a, b = pair.split(":")
mark = 'совпадают' if equal else 'различаются'
pairs.append(f"
{METHODS[a].title} и {METHODS[b].title.lower()}: {mark}
")
pairs_html = f"
{''.join(pairs)}
" if pairs else ""
tolerance = ("Точные ответы сравниваются символьно или с относительной погрешностью 10−9, "
"с численной проверкой — с погрешностью 1%.")
return f"""
Разбор условия
{html.escape(analysis.summary) or '—'}
Тема: {analysis.topic_title}. Вид ответа: {html.escape(analysis.answer_format)}.
Ответы методов
Метод
Ответ
Статус
Время
Попыток
Токенов
{''.join(rows)}
Сверка
{pairs_html}
{tolerance} Ответ выдаётся, если ответили хотя бы два метода, все ответы совпали и среди них есть точный.