"""Helpers that turn raw model output into Markdown for the chatbot widget.""" from __future__ import annotations import json import re from typing import Any from i18n import t # KaTeX renders negation macros (\neq, \notin, …) via \not + SVG slash # overlays. The chat bubble's inflated line-height stretches those SVGs # into solid-black rectangles. Replacing with Unicode avoids the SVG # path entirely — KaTeX renders the single glyph from its math fonts # instead. # # The dict covers both single-macro forms (\neq) and \not compound forms # (\not=, \not\equiv). Sorted longest-first when building the regex so # longer patterns match before shorter prefixes. _NEGATION_UNICODE: dict[str, str] = { # \not + symbol compounds r"\not\subseteq": "⊈", r"\not\supseteq": "⊉", r"\not\parallel": "∦", r"\not\approx": "≉", r"\not\subset": "⊄", r"\not\supset": "⊅", r"\not\simeq": "≄", r"\not\exists": "∄", r"\not\equiv": "≢", r"\not\cong": "≇", r"\not\leq": "≰", r"\not\geq": "≱", r"\not\sim": "≁", r"\not\mid": "∤", r"\not\le": "≰", r"\not\ge": "≱", r"\not\in": "∉", r"\not\ni": "∌", r"\not=": "≠", r"\not<": "≮", r"\not>": "≯", # Standard single-macro negations r"\nsubseteq": "⊈", r"\nsupseteq": "⊉", r"\nparallel": "∦", r"\nexists": "∄", r"\nequiv": "≢", r"\notin": "∉", r"\nless": "≮", r"\nleq": "≰", r"\ngeq": "≱", r"\ngtr": "≯", r"\nmid": "∤", r"\ncong": "≇", r"\nsim": "≁", r"\neq": "≠", r"\ne": "≠", } _NEGATION_RE = re.compile( "(" + "|".join( re.escape(m) for m in sorted(_NEGATION_UNICODE, key=len, reverse=True) ) + r")(?![a-zA-Z])", ) _NOT_SPACE_RE = re.compile(r"\\not\s+(?=[\[=<>]|\\[a-zA-Z])") def _replace_negation_macros(text: str) -> str: """Swap negation macros for Unicode glyphs to bypass SVG overlays.""" # Collapse optional whitespace after \not so \not \equiv → \not\equiv text = _NOT_SPACE_RE.sub(r"\\not", text) return _NEGATION_RE.sub(lambda m: _NEGATION_UNICODE[m.group(1)], text) # LaTeX environments that should be treated as display math. _MATH_ENVS = ( "equation", "align", "aligned", "gather", "gathered", "multline", "cases", "array", "split", "matrix", "bmatrix", "pmatrix", "vmatrix", "Vmatrix", "eqnarray", ) _ENV_NAMES = "|".join(_MATH_ENVS) # Matches \begin{env}...\end{env} blocks NOT already inside $$ delimiters. # Uses a backreference (\2) to ensure begin/end names match. _BARE_ENV_RE = re.compile( r"(? str: r"""Wrap bare \begin{env}...\end{env} blocks in $$ for KaTeX. Models sometimes output LaTeX environments without wrapping them in display-math delimiters. KaTeX only renders them when they appear inside ``$$...$$`` or ``\[...\]``. """ return _BARE_ENV_RE.sub(r"\n\n$$\1$$\n\n", text) def _escape_latex_delimiters(text: str) -> str: r"""Replace LaTeX delimiters with HTML entities so a downstream Markdown / KaTeX pass does not match them across the
boundary. Covers ``\(...\)``, ``\[...\]``, ``$...$``, and ``$$...$$``. """ return ( text .replace("\\(", "\(") .replace("\\)", "\)") .replace("\\[", "\[") .replace("\\]", "\]") .replace("$", "$") ) def preprocess_latex(text: str) -> str: """Normalize model output so KaTeX renders math correctly. Applied to the assistant *content* (not thinking) before display. """ text = _replace_negation_macros(text) text = _wrap_bare_latex_envs(text) return text def build_display_content(reasoning: str, content: str) -> str: """Wrap reasoning + content into a collapsible Markdown block.""" parts: list[str] = [] if reasoning: is_complete = bool(content) summary = t("display.thinking_done") if is_complete else t("display.thinking") open_attr = "" if is_complete else " open" safe_reasoning = _escape_latex_delimiters(reasoning.strip()) parts.append( f'
' f'{summary}\n\n{safe_reasoning}\n\n
\n\n' ) if content: parts.append(preprocess_latex(content)) return "".join(parts) def format_tool_calls_for_display(tool_calls: list[Any]) -> str: return "\n\n".join(_format_single_tool_call(tc) for tc in tool_calls) def format_tool_call_prompt(tc: Any, current_idx: int, total: int) -> str: header = f"**{t('tool.call_header', i=current_idx, n=total)}**\n\n" return header + _format_single_tool_call(tc) def _format_single_tool_call(tc: Any) -> str: if isinstance(tc, dict): name = tc["function"]["name"] args_raw = tc["function"]["arguments"] else: name = tc.function.name args_raw = tc.function.arguments try: args_formatted = json.dumps(json.loads(args_raw), indent=2, ensure_ascii=False) except (json.JSONDecodeError, TypeError): args_formatted = args_raw return f"{t('tool.call_label')}: **{name}**\n```json\n{args_formatted}\n```"