diff --git a/README.md b/README.md index 6880e13..c677253 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.json.template b/config.json.template index ce123f8..ecf34cb 100644 --- a/config.json.template +++ b/config.json.template @@ -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." } diff --git a/config.math-word.template b/config.math-word.template new file mode 100644 index 0000000..f6c0c8f --- /dev/null +++ b/config.math-word.template @@ -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]." +} diff --git a/src/ai_typewriter/config.py b/src/ai_typewriter/config.py index fbe283b..026c30d 100644 --- a/src/ai_typewriter/config.py +++ b/src/ai_typewriter/config.py @@ -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)), ) diff --git a/src/ai_typewriter/math_format.py b/src/ai_typewriter/math_format.py index 2d4c9c2..f02ef4b 100644 --- a/src/ai_typewriter/math_format.py +++ b/src/ai_typewriter/math_format.py @@ -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": diff --git a/tests/test_config.py b/tests/test_config.py index a78819a..58dfb01 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_math_format.py b/tests/test_math_format.py index f885a3b..93def03 100644 --- a/tests/test_math_format.py +++ b/tests/test_math_format.py @@ -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")