Use explicit Word equation markers
This commit is contained in:
@@ -40,7 +40,7 @@ Copiez `config.json.template` vers `config.json` puis adaptez :
|
||||
"server_url": "http://localhost:11434",
|
||||
"hotkey": "ctrl+alt+a",
|
||||
"request_timeout_seconds": 300,
|
||||
"math_text_format": "word_equation"
|
||||
"math_text_format": "plain"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,28 +69,41 @@ Copiez `config.json.template` vers `config.json` puis adaptez :
|
||||
|
||||
`request_timeout_seconds` vaut `300` par défaut. Si Ollama charge un gros modèle ou répond lentement, augmentez cette valeur. Mettez `0` pour désactiver le timeout côté application.
|
||||
|
||||
### Formatage mathématique Word
|
||||
### Mode mathématique Word
|
||||
|
||||
`math_text_format` vaut `word_equation` par défaut. Dans ce mode, les segments mathématiques comme `z_1`, `x^2`, `\alpha` ou `z_1 = x + iy` sont injectés dans Word comme de vraies équations :
|
||||
La configuration générale reste simple (`math_text_format: "plain"`). Pour les réponses mathématiques, utilisez l'exemple dédié :
|
||||
|
||||
1. l'application envoie `Alt+=` ;
|
||||
2. elle tape l'expression en syntaxe UnicodeMath Word ;
|
||||
3. elle envoie `Espace` pour laisser Word convertir ;
|
||||
4. elle sort de l'objet équation avec `Flèche droite`.
|
||||
|
||||
Exemple :
|
||||
|
||||
```text
|
||||
On pose z_1 = x + iy.
|
||||
```bash
|
||||
cp config.math-word.template config.json
|
||||
```
|
||||
|
||||
est injecté comme texte normal `On pose `, puis une équation Word `z_1 = x + iy`, puis le point final.
|
||||
Ce fichier active :
|
||||
|
||||
```json
|
||||
"math_text_format": "word_equation"
|
||||
```
|
||||
|
||||
Dans ce mode, l'application ne devine plus les maths automatiquement. Elle suit uniquement les balises explicites renvoyées par le modèle :
|
||||
|
||||
```text
|
||||
La solution est [EQ]z_1 = x + iy[/EQ].
|
||||
```
|
||||
|
||||
Injection réelle :
|
||||
|
||||
1. texte normal : `La solution est ` ;
|
||||
2. à `[EQ]`, l'application envoie `Alt+=` ;
|
||||
3. elle tape `z_1 = x + iy` dans l'éditeur d'équation Word ;
|
||||
4. à `[/EQ]`, elle envoie `Espace` puis `Flèche droite` pour convertir et sortir de l'équation ;
|
||||
5. elle reprend le texte normal : `.`.
|
||||
|
||||
Le prompt de `config.math-word.template` demande aussi au modèle d'éviter le LaTeX non souhaité, notamment `\\frac`, `\\dfrac`, `\\tfrac`, `\\left`, `\\right`, `$`, `$$`, `\\(` et `\\[`.
|
||||
|
||||
Modes disponibles :
|
||||
|
||||
- `word_equation` : crée des objets Équation Word via `Alt+=` pour les segments mathématiques détectés.
|
||||
- `unicode` : transforme `z_1`, `x^2`, `\alpha`, etc. en texte Unicode (`z₁`, `x²`, `α`) sans objet équation.
|
||||
- `plain` : injecte la réponse exactement telle que le modèle l'a renvoyée.
|
||||
- `word_equation` : crée des objets Équation Word uniquement pour les blocs `[EQ]...[/EQ]`.
|
||||
- `unicode` : ancien mode texte Unicode (`z_1` → `z₁`, `x^2` → `x²`) sans objet équation.
|
||||
|
||||
## Compilation
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"server_url": "http://localhost:11434",
|
||||
"hotkey": "ctrl+alt+a",
|
||||
"request_timeout_seconds": 300,
|
||||
"math_text_format": "word_equation",
|
||||
"math_text_format": "plain",
|
||||
"type_delay_seconds": 0,
|
||||
"system_prompt": "Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1",
|
||||
"api_key": "",
|
||||
"server_url": "http://localhost:11434",
|
||||
"hotkey": "ctrl+alt+a",
|
||||
"request_timeout_seconds": 300,
|
||||
"math_text_format": "word_equation",
|
||||
"type_delay_seconds": 0,
|
||||
"system_prompt": "Réponds directement et de manière ultra-concise. Aucune salutation, aucune introduction, aucun Markdown. Pour toute formule mathématique qui doit devenir une équation Microsoft Word, écris exactement [EQ] avant la formule et [/EQ] après la formule. Le texte hors des balises restera du texte normal. Dans les balises [EQ], utilise la syntaxe UnicodeMath de Microsoft Word, pas LaTeX. N'utilise jamais \\frac, \\dfrac, \\tfrac, \\left, \\right, $, $$, \\( ou \\[. Pour les fractions, utilise a/b ou (a+b)/(c+d). Pour les indices et exposants, utilise z_1, x^2, x_(n+1). Pour les racines, utilise sqrt(x). Pour les lettres grecques, utilise \\alpha, \\beta, \\Delta. Exemple de sortie valide : La solution est [EQ]z_1 = x + iy[/EQ]."
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class AppConfig:
|
||||
request_timeout_seconds: float | None = 300.0
|
||||
copy_wait_seconds: float = 1.0
|
||||
type_delay_seconds: float = 0.0
|
||||
math_text_format: MathTextFormat = "word_equation"
|
||||
math_text_format: MathTextFormat = "plain"
|
||||
restore_clipboard: bool = True
|
||||
system_prompt: str = (
|
||||
"Réponds directement et de manière ultra-concise. "
|
||||
@@ -43,7 +43,7 @@ def _coerce_timeout(value: Any) -> float | None:
|
||||
|
||||
|
||||
def _coerce_math_text_format(value: Any) -> MathTextFormat:
|
||||
mode = str(value or "word_equation").lower().strip()
|
||||
mode = str(value or "plain").lower().strip()
|
||||
if mode not in {"plain", "unicode", "unicode_math", "word_equation"}:
|
||||
raise ValueError("config.math_text_format doit être 'plain', 'unicode' ou 'word_equation'")
|
||||
return mode # type: ignore[return-value]
|
||||
@@ -65,7 +65,7 @@ def load_config(path: str | Path = "config.json") -> AppConfig:
|
||||
request_timeout_seconds=_coerce_timeout(data.get("request_timeout_seconds", 300)),
|
||||
copy_wait_seconds=float(data.get("copy_wait_seconds", 1)),
|
||||
type_delay_seconds=float(data.get("type_delay_seconds", 0)),
|
||||
math_text_format=_coerce_math_text_format(data.get("math_text_format", "word_equation")),
|
||||
math_text_format=_coerce_math_text_format(data.get("math_text_format", "plain")),
|
||||
restore_clipboard=bool(data.get("restore_clipboard", True)),
|
||||
system_prompt=str(data.get("system_prompt", AppConfig.system_prompt)),
|
||||
)
|
||||
|
||||
@@ -9,36 +9,14 @@ GREEK_AND_SYMBOLS = {
|
||||
r"\gamma": "γ",
|
||||
r"\delta": "δ",
|
||||
r"\epsilon": "ε",
|
||||
r"\varepsilon": "ε",
|
||||
r"\zeta": "ζ",
|
||||
r"\eta": "η",
|
||||
r"\theta": "θ",
|
||||
r"\vartheta": "ϑ",
|
||||
r"\iota": "ι",
|
||||
r"\kappa": "κ",
|
||||
r"\lambda": "λ",
|
||||
r"\mu": "μ",
|
||||
r"\nu": "ν",
|
||||
r"\xi": "ξ",
|
||||
r"\pi": "π",
|
||||
r"\rho": "ρ",
|
||||
r"\sigma": "σ",
|
||||
r"\tau": "τ",
|
||||
r"\upsilon": "υ",
|
||||
r"\phi": "φ",
|
||||
r"\varphi": "φ",
|
||||
r"\chi": "χ",
|
||||
r"\psi": "ψ",
|
||||
r"\omega": "ω",
|
||||
r"\Gamma": "Γ",
|
||||
r"\Delta": "Δ",
|
||||
r"\Theta": "Θ",
|
||||
r"\Lambda": "Λ",
|
||||
r"\Xi": "Ξ",
|
||||
r"\Pi": "Π",
|
||||
r"\Sigma": "Σ",
|
||||
r"\Phi": "Φ",
|
||||
r"\Psi": "Ψ",
|
||||
r"\Omega": "Ω",
|
||||
r"\infty": "∞",
|
||||
r"\leq": "≤",
|
||||
@@ -51,31 +29,11 @@ GREEK_AND_SYMBOLS = {
|
||||
r"\times": "×",
|
||||
r"\cdot": "·",
|
||||
r"\pm": "±",
|
||||
r"\mp": "∓",
|
||||
r"\to": "→",
|
||||
r"\rightarrow": "→",
|
||||
r"\leftarrow": "←",
|
||||
r"\Rightarrow": "⇒",
|
||||
r"\Leftrightarrow": "⇔",
|
||||
r"\forall": "∀",
|
||||
r"\exists": "∃",
|
||||
r"\in": "∈",
|
||||
r"\notin": "∉",
|
||||
r"\subset": "⊂",
|
||||
r"\subseteq": "⊆",
|
||||
r"\cup": "∪",
|
||||
r"\cap": "∩",
|
||||
r"\emptyset": "∅",
|
||||
r"\mathbb{R}": "ℝ",
|
||||
r"\mathbb{C}": "ℂ",
|
||||
r"\mathbb{N}": "ℕ",
|
||||
r"\mathbb{Z}": "ℤ",
|
||||
r"\mathbb{Q}": "ℚ",
|
||||
r"\int": "∫",
|
||||
r"\sum": "∑",
|
||||
r"\prod": "∏",
|
||||
r"\partial": "∂",
|
||||
r"\nabla": "∇",
|
||||
r"\sqrt": "√",
|
||||
}
|
||||
|
||||
SUPERSCRIPT = str.maketrans({
|
||||
@@ -94,18 +52,9 @@ SUBSCRIPT = str.maketrans({
|
||||
"s": "ₛ", "t": "ₜ", "u": "ᵤ", "v": "ᵥ", "x": "ₓ",
|
||||
})
|
||||
|
||||
_GROUP_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])")
|
||||
_MATH_START_PATTERN = re.compile(
|
||||
r"(\\[A-Za-z]+|[A-Za-zΑ-Ωα-ω]+(?:[_^](?:\([^()]+\)|\{[^{}]+\}|[A-Za-z0-9+\-=]))+|[∫∑∞≤≥≠≈α-ωΑ-Ω]|=)"
|
||||
)
|
||||
_MATH_TOKEN_PATTERN = re.compile(
|
||||
r"\s*(?:"
|
||||
r"\\[A-Za-z]+(?:\([^()]+\)|\{[^{}]+\})?"
|
||||
r"|[A-Za-zΑ-Ωα-ω0-9]+(?:[_^](?:\([^()]+\)|\{[^{}]+\}|[A-Za-z0-9+\-=]))*"
|
||||
r"|[=+\-*/×·≤≥≠≈<>]"
|
||||
r"|[(){}]"
|
||||
r")"
|
||||
)
|
||||
_SCRIPT_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])")
|
||||
EQUATION_OPEN = "[EQ]"
|
||||
EQUATION_CLOSE = "[/EQ]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -127,75 +76,54 @@ def _replace_script(match: re.Match[str]) -> str:
|
||||
|
||||
|
||||
def format_math_text(text: str, mode: str = "plain") -> str:
|
||||
r"""Convert common UnicodeMath/LaTeX-like linear math to Unicode plain text.
|
||||
|
||||
This is meant for Word normal text insertion. It makes expressions such as
|
||||
z_1, x^2, \alpha and \infty render as z₁, x², α and ∞ without requiring the
|
||||
user to manually open Word's equation editor for every expression.
|
||||
"""
|
||||
"""Optional legacy text-only math formatting for non-Word targets."""
|
||||
if mode == "plain":
|
||||
return text
|
||||
if mode not in {"unicode", "unicode_math"}:
|
||||
raise ValueError("math_text_format doit être 'plain', 'unicode' ou 'word_equation'")
|
||||
|
||||
formatted = text
|
||||
# Replace longer commands first so \subseteq wins before \subset.
|
||||
for command, replacement in sorted(GREEK_AND_SYMBOLS.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
formatted = formatted.replace(command, replacement)
|
||||
|
||||
formatted = re.sub(r"\\vec\(([^()]+)\)", lambda match: match.group(1) + "⃗", formatted)
|
||||
formatted = formatted.replace(r"\sqrt", "√")
|
||||
previous = None
|
||||
while previous != formatted:
|
||||
previous = formatted
|
||||
formatted = _GROUP_PATTERN.sub(_replace_script, formatted)
|
||||
formatted = _SCRIPT_PATTERN.sub(_replace_script, formatted)
|
||||
return formatted
|
||||
|
||||
|
||||
def _consume_math_sequence(text: str, start: int) -> int:
|
||||
end = start
|
||||
while end < len(text):
|
||||
if text[end] in "\n.,;:!?":
|
||||
break
|
||||
match = _MATH_TOKEN_PATTERN.match(text, end)
|
||||
if match is None:
|
||||
break
|
||||
end = match.end()
|
||||
return end
|
||||
|
||||
|
||||
def split_word_equation_segments(text: str) -> list[MathSegment]:
|
||||
r"""Split text into normal text and Word UnicodeMath equation insertions.
|
||||
"""Split explicit [EQ]...[/EQ] blocks into Word equation actions.
|
||||
|
||||
Equation segments keep Word's linear UnicodeMath syntax (z_1, x^2, \alpha),
|
||||
because Word converts that syntax inside the Alt+= equation editor.
|
||||
No automatic math guessing is done here: the model decides where an equation
|
||||
starts and ends by returning [EQ] before the expression and [/EQ] after it.
|
||||
"""
|
||||
segments: list[MathSegment] = []
|
||||
cursor = 0
|
||||
|
||||
while cursor < len(text):
|
||||
match = _MATH_START_PATTERN.search(text, cursor)
|
||||
if match is None:
|
||||
start = text.find(EQUATION_OPEN, cursor)
|
||||
if start == -1:
|
||||
if cursor < len(text):
|
||||
segments.append(MathSegment("text", text[cursor:]))
|
||||
break
|
||||
|
||||
start = match.start()
|
||||
if start > cursor:
|
||||
segments.append(MathSegment("text", text[cursor:start]))
|
||||
|
||||
end = _consume_math_sequence(text, start)
|
||||
expression = text[start:end].strip()
|
||||
leading = text[start : start + len(text[start:end]) - len(text[start:end].lstrip())]
|
||||
trailing = text[start:end][len(text[start:end].rstrip()):]
|
||||
if leading:
|
||||
segments.append(MathSegment("text", leading))
|
||||
expression_start = start + len(EQUATION_OPEN)
|
||||
end = text.find(EQUATION_CLOSE, expression_start)
|
||||
if end == -1:
|
||||
expression = text[expression_start:].strip()
|
||||
cursor = len(text)
|
||||
else:
|
||||
expression = text[expression_start:end].strip()
|
||||
cursor = end + len(EQUATION_CLOSE)
|
||||
|
||||
if expression:
|
||||
segments.append(MathSegment("equation", expression))
|
||||
if trailing:
|
||||
segments.append(MathSegment("text", trailing))
|
||||
cursor = end
|
||||
|
||||
# Merge adjacent text segments for cleaner action counts.
|
||||
merged: list[MathSegment] = []
|
||||
for segment in segments:
|
||||
if merged and segment.kind == "text" and merged[-1].kind == "text":
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_load_default_config(tmp_path):
|
||||
|
||||
assert cfg.provider == "ollama"
|
||||
assert cfg.hotkey == "ctrl+alt+a"
|
||||
assert cfg.math_text_format == "word_equation"
|
||||
assert cfg.math_text_format == "plain"
|
||||
assert "Réponds directement" in cfg.system_prompt
|
||||
|
||||
|
||||
|
||||
@@ -13,14 +13,8 @@ def test_unicode_mode_converts_indices_exponents_and_symbols():
|
||||
assert result == "z₁ + x² + xᵢ₊₁ + α + ∞"
|
||||
|
||||
|
||||
def test_unicode_mode_converts_common_operators():
|
||||
result = format_math_text(r"\int_0^1 f(x) dx \leq \sum_(k=1)^n a_k", "unicode")
|
||||
|
||||
assert result == "∫₀¹ f(x) dx ≤ ∑ₖ₌₁ⁿ aₖ"
|
||||
|
||||
|
||||
def test_word_equation_segments_keep_word_unicodemath_syntax():
|
||||
segments = split_word_equation_segments(r"On pose z_1 = x + iy. Puis \alpha = 2.")
|
||||
def test_marker_mode_only_uses_explicit_equation_blocks():
|
||||
segments = split_word_equation_segments(r"On pose [EQ]z_1 = x + iy[/EQ]. Puis [EQ]\alpha = 2[/EQ].")
|
||||
|
||||
assert [(segment.kind, segment.value) for segment in segments] == [
|
||||
("text", "On pose "),
|
||||
@@ -31,6 +25,23 @@ def test_word_equation_segments_keep_word_unicodemath_syntax():
|
||||
]
|
||||
|
||||
|
||||
def test_marker_mode_does_not_guess_math_without_markers():
|
||||
segments = split_word_equation_segments(r"z_1 reste du texte normal sans marqueur.")
|
||||
|
||||
assert [(segment.kind, segment.value) for segment in segments] == [
|
||||
("text", r"z_1 reste du texte normal sans marqueur."),
|
||||
]
|
||||
|
||||
|
||||
def test_unclosed_equation_marker_consumes_the_rest():
|
||||
segments = split_word_equation_segments(r"Résultat: [EQ]x^2 + 1")
|
||||
|
||||
assert [(segment.kind, segment.value) for segment in segments] == [
|
||||
("text", "Résultat: "),
|
||||
("equation", "x^2 + 1"),
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_math_text_format_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
format_math_text("x_1", "bad")
|
||||
|
||||
Reference in New Issue
Block a user