File size: 5,588 Bytes
ad51766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
"""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"(?<!\$)"
    r"(\\begin\{(" + _ENV_NAMES + r")\*?\}"
    r"[\s\S]*?"
    r"\\end\{\2\*?\})"
    r"(?!\$)",
)


def _wrap_bare_latex_envs(text: str) -> 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 <details>
    boundary.

    Covers ``\(...\)``, ``\[...\]``, ``$...$``, and ``$$...$$``.
    """
    return (
        text
        .replace("\\(", "&#92;(")
        .replace("\\)", "&#92;)")
        .replace("\\[", "&#92;[")
        .replace("\\]", "&#92;]")
        .replace("$", "&#36;")
    )


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'<details class="thinking-block"{open_attr}>'
            f'<summary>{summary}</summary>\n\n{safe_reasoning}\n\n</details>\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```"