""" 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]+;|&#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'' ) 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'{markup}' 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

Решает задачу тремя независимыми способами и выдаёт ответ, только если они сошлись. Если методы расходятся, решатель честно говорит, что надёжного ответа нет.

{steps}
""" def _stage(name: str, title: str, state: str, body: str, meta: str = "") -> str: labels = {"wait": "ожидает", "run": "в работе", "done": "готово", "fail": "нет ответа", "skip": "не нужен", "ok": "совпал", "bad": "расходится"} spinner = '' if state == "run" else "" return f"""
{icon(name)}{title} {spinner}{labels[state]}
{body}
{f'
{meta}
' if meta else ''}
""" 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"{METHODS[key].title}{answer}{status}" f"{result.seconds:.1f} с{result.attempts}" f"{result.usage.prompt_tokens + result.usage.completion_tokens:,}".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} Ответ выдаётся, если ответили хотя бы два метода, все ответы совпали и среди них есть точный.

    """