From d07bed60e67a7d1b2d1fda448f181b2ee08b2289 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 12 Sep 2026 12:17:07 +0200 Subject: [PATCH] Return to plain LaTeX typing mode --- README.md | 42 +++++++++++------------ config.math-latex.template | 11 ++++++ config.math-word.template | 11 ------ src/ai_typewriter/app.py | 27 ++++----------- src/ai_typewriter/config.py | 6 ++-- src/ai_typewriter/key_stepper.py | 48 +++++---------------------- src/ai_typewriter/math_format.py | 57 ++++---------------------------- tests/test_config.py | 6 ++-- tests/test_key_stepper.py | 38 ++++++++++++++++----- tests/test_math_format.py | 35 ++------------------ 10 files changed, 90 insertions(+), 191 deletions(-) create mode 100644 config.math-latex.template delete mode 100644 config.math-word.template diff --git a/README.md b/README.md index c677253..9e171fb 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,14 @@ Application Python qui lit la dernière entrée texte du presse-papier avec un r 1. L'utilisateur copie manuellement le texte à envoyer à l'IA. 2. Raccourci global par défaut : `Ctrl+Alt+A`. 3. L'application lit directement la dernière entrée du presse-papier, sans simuler `Ctrl+C`. -4. Le texte capturé est journalisé puis envoyé à Ollama ou Gemini avec le system prompt strict : - - > Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute. - +4. Le texte capturé est journalisé puis envoyé à Ollama ou Gemini. 5. Le temps de génération de la réponse est journalisé. -6. Quand la réponse arrive, le premier caractère est écrit automatiquement. -7. Le mode dactylographie s'active ensuite : chaque touche physique est interceptée et remplacée par le prochain caractère de la réponse. +6. Quand la réponse arrive, le mode dactylographie s'active. +7. Chaque touche physique appuyée est interceptée et remplacée par le prochain caractère de la réponse IA. 8. Le hook clavier est libéré automatiquement après le dernier caractère. +L'application n'interprète pas les équations et ne lance pas `Alt+=`. Elle écrit uniquement le texte généré, caractère par caractère. Pour les maths, le modèle peut produire du LaTeX encadré par des marqueurs `[EQ]...[/EQ]`, puis la gestion Word peut être faite ailleurs. + ## Installation depuis les sources ```bash @@ -69,40 +68,37 @@ 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. -### Mode mathématique Word +### Configuration maths LaTeX -La configuration générale reste simple (`math_text_format: "plain"`). Pour les réponses mathématiques, utilisez l'exemple dédié : +Pour laisser le modèle générer du LaTeX tout en indiquant clairement les débuts/fins d'équations : ```bash -cp config.math-word.template config.json +cp config.math-latex.template config.json ``` -Ce fichier active : +Cette config garde : ```json -"math_text_format": "word_equation" +"math_text_format": "plain" ``` -Dans ce mode, l'application ne devine plus les maths automatiquement. Elle suit uniquement les balises explicites renvoyées par le modèle : +Donc l'application ne transforme rien. Elle tape littéralement la réponse reçue, caractère par caractère. + +Exemple de réponse demandée au modèle : ```text -La solution est [EQ]z_1 = x + iy[/EQ]. +Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ]. ``` -Injection réelle : +Pour les fractions, intégrales, sommes, etc., le modèle peut utiliser du LaTeX standard dans les balises : -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 `\\[`. +```text +On obtient [EQ]\frac{a+b}{c+d}[/EQ] puis [EQ]\int_0^1 f(x)\,dx[/EQ]. +``` Modes disponibles : -- `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]`. +- `plain` : mode recommandé ; injecte la réponse exactement telle que le modèle l'a renvoyée. - `unicode` : ancien mode texte Unicode (`z_1` → `z₁`, `x^2` → `x²`) sans objet équation. ## Compilation diff --git a/config.math-latex.template b/config.math-latex.template new file mode 100644 index 0000000..e2867ee --- /dev/null +++ b/config.math-latex.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": "plain", + "type_delay_seconds": 0, + "system_prompt": "Tu réponds directement, sans salutation ni introduction. Rédige une réponse claire, correcte et concise. Pour toute expression mathématique, formule, calcul, égalité, fraction, somme, intégrale, matrice ou symbole qui doit être traité comme une équation, encadre exactement le bloc avec [EQ] au début et [/EQ] à la fin. Dans ces blocs, écris du LaTeX standard, car il est plus simple et fiable à générer : \frac{a}{b}, z_1, x^2, \int_0^1, \sum_{k=1}^n, etc. N'utilise pas de délimiteurs LaTeX supplémentaires dans les blocs : pas de $, $$, \\(, \\[. Le texte hors des balises [EQ]...[/EQ] reste du texte normal. Exemple valide : Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ]." +} diff --git a/config.math-word.template b/config.math-word.template deleted file mode 100644 index f6c0c8f..0000000 --- a/config.math-word.template +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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/app.py b/src/ai_typewriter/app.py index aaadbea..e70dbaa 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -11,22 +11,16 @@ import keyboard from .ai_client import AIClientError, ask_ai from .clipboard_capture import capture_clipboard from .config import AppConfig, load_config -from .key_stepper import KeyStepper, TypeAction -from .math_format import format_math_text, split_word_equation_segments +from .key_stepper import KeyStepper +from .math_format import format_math_text LOG = logging.getLogger("ai_typewriter") -def prepare_type_payload(answer: str, mode: str) -> str | list[TypeAction]: - if mode == "word_equation": - return [TypeAction(segment.kind, segment.value) for segment in split_word_equation_segments(answer)] +def prepare_type_payload(answer: str, mode: str) -> str: return format_math_text(answer, mode) -def payload_length(payload: str | list[TypeAction]) -> int: - return len(payload) - - class AITypewriterApp: def __init__(self, config: AppConfig) -> None: self.config = config @@ -55,15 +49,12 @@ class AITypewriterApp: started_at = time.perf_counter() answer = ask_ai(selected, self.config) answer_to_type = prepare_type_payload(answer, self.config.math_text_format) - typed_units = payload_length(answer_to_type) elapsed = time.perf_counter() - started_at LOG.info( - "Réponse reçue en %.2f s: %d caractères (%d unités à injecter en mode %s). Premier caractère/action écrit automatiquement, %d restants au clavier.", + "Réponse reçue en %.2f s: %d caractères à écrire en mode %s. Appuyez sur une touche pour écrire chaque caractère.", elapsed, - len(answer), - typed_units, + len(answer_to_type), self.config.math_text_format, - max(typed_units - 1, 0), ) if self._stepper is not None: self._stepper.stop() @@ -100,13 +91,9 @@ def main(argv: list[str] | None = None) -> int: started_at = time.perf_counter() answer = ask_ai(args.ask, config) answer_to_type = prepare_type_payload(answer, config.math_text_format) - typed_units = payload_length(answer_to_type) elapsed = time.perf_counter() - started_at - LOG.info("Réponse générée en %.2f s: %d caractères (%d unités en mode %s).", elapsed, len(answer), typed_units, config.math_text_format) - if isinstance(answer_to_type, str): - print(answer_to_type) - else: - print("".join(f"[Alt+= {action.value}]" if action.kind == "equation" else action.value for action in answer_to_type)) + LOG.info("Réponse générée en %.2f s: %d caractères en mode %s.", elapsed, len(answer_to_type), config.math_text_format) + print(answer_to_type) return 0 except AIClientError as exc: LOG.error("%s", exc) diff --git a/src/ai_typewriter/config.py b/src/ai_typewriter/config.py index 026c30d..21a06b2 100644 --- a/src/ai_typewriter/config.py +++ b/src/ai_typewriter/config.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Literal Provider = Literal["ollama", "gemini"] -MathTextFormat = Literal["plain", "unicode", "unicode_math", "word_equation"] +MathTextFormat = Literal["plain", "unicode", "unicode_math"] @dataclass(frozen=True) @@ -44,8 +44,8 @@ def _coerce_timeout(value: Any) -> float | None: def _coerce_math_text_format(value: Any) -> MathTextFormat: 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'") + if mode not in {"plain", "unicode", "unicode_math"}: + raise ValueError("config.math_text_format doit être 'plain' ou 'unicode'") return mode # type: ignore[return-value] diff --git a/src/ai_typewriter/key_stepper.py b/src/ai_typewriter/key_stepper.py index 4ad69b5..f272047 100644 --- a/src/ai_typewriter/key_stepper.py +++ b/src/ai_typewriter/key_stepper.py @@ -2,63 +2,47 @@ from __future__ import annotations from dataclasses import dataclass, field from threading import Lock -from typing import Optional, Sequence +from typing import Optional import keyboard -@dataclass(frozen=True) -class TypeAction: - kind: str - value: str - - @dataclass class KeyStepper: - text: str | Sequence[TypeAction] + text: str delay: float = 0.0 _index: int = 0 _hook: Optional[object] = None _lock: Lock = field(default_factory=Lock) _injecting: bool = False - _actions: list[TypeAction] = field(init=False) - - def __post_init__(self) -> None: - if isinstance(self.text, str): - self._actions = [TypeAction("text", char) for char in self.text] - else: - self._actions = list(self.text) def start(self) -> None: - if not self._actions: + if not self.text: return if self._hook is not None: return - self.type_next_character() - if self._index >= len(self._actions): - return - # suppress=True blocks the physical key while the callback types the next AI character/action. + # suppress=True blocks the physical key while the callback types the next AI character. self._hook = keyboard.hook(self._on_event, suppress=True) @property def remaining_characters(self) -> int: - return max(len(self._actions) - self._index, 0) + return max(len(self.text) - self._index, 0) def type_next_character(self) -> None: with self._lock: - if self._index >= len(self._actions): + if self._index >= len(self.text): self.stop() return - action = self._actions[self._index] + char = self.text[self._index] self._index += 1 self._injecting = True try: - self._type_action(action) + self._type_char(char) finally: self._injecting = False - if self._index >= len(self._actions): + if self._index >= len(self.text): self.stop() def stop(self) -> None: @@ -72,20 +56,6 @@ class KeyStepper: return self.type_next_character() - def _type_action(self, action: TypeAction) -> None: - if action.kind == "equation": - self._type_word_equation(action.value) - return - for char in action.value: - self._type_char(char) - - def _type_word_equation(self, expression: str) -> None: - keyboard.send("alt+=") - keyboard.write(expression, delay=self.delay, exact=True) - # Word converts much of UnicodeMath after Space; Right exits the equation object. - keyboard.send("space") - keyboard.send("right") - def _type_char(self, char: str) -> None: if char == "\n": keyboard.send("enter") diff --git a/src/ai_typewriter/math_format.py b/src/ai_typewriter/math_format.py index f02ef4b..797079d 100644 --- a/src/ai_typewriter/math_format.py +++ b/src/ai_typewriter/math_format.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -from dataclasses import dataclass GREEK_AND_SYMBOLS = { r"\alpha": "α", @@ -53,14 +52,6 @@ SUBSCRIPT = str.maketrans({ }) _SCRIPT_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])") -EQUATION_OPEN = "[EQ]" -EQUATION_CLOSE = "[/EQ]" - - -@dataclass(frozen=True) -class MathSegment: - kind: str - value: str def _translate_script(value: str, marker: str) -> str: @@ -76,11 +67,15 @@ def _replace_script(match: re.Match[str]) -> str: def format_math_text(text: str, mode: str = "plain") -> str: - """Optional legacy text-only math formatting for non-Word targets.""" + """Format the AI answer before key stepping. + + plain keeps the response unchanged, which is the recommended mode for LaTeX + markers such as [EQ]\\frac{a}{b}[/EQ]. + """ if mode == "plain": return text if mode not in {"unicode", "unicode_math"}: - raise ValueError("math_text_format doit être 'plain', 'unicode' ou 'word_equation'") + raise ValueError("math_text_format doit être 'plain' ou 'unicode'") formatted = text for command, replacement in sorted(GREEK_AND_SYMBOLS.items(), key=lambda item: len(item[0]), reverse=True): @@ -91,43 +86,3 @@ def format_math_text(text: str, mode: str = "plain") -> str: previous = formatted formatted = _SCRIPT_PATTERN.sub(_replace_script, formatted) return formatted - - -def split_word_equation_segments(text: str) -> list[MathSegment]: - """Split explicit [EQ]...[/EQ] blocks into Word equation actions. - - 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): - start = text.find(EQUATION_OPEN, cursor) - if start == -1: - if cursor < len(text): - segments.append(MathSegment("text", text[cursor:])) - break - - if start > cursor: - segments.append(MathSegment("text", text[cursor:start])) - - 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)) - - merged: list[MathSegment] = [] - for segment in segments: - if merged and segment.kind == "text" and merged[-1].kind == "text": - merged[-1] = MathSegment("text", merged[-1].value + segment.value) - else: - merged.append(segment) - return merged diff --git a/tests/test_config.py b/tests/test_config.py index 58dfb01..3b40063 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,13 +17,13 @@ def test_load_default_config(tmp_path): assert "Réponds directement" in cfg.system_prompt -def test_accept_word_equation_format(tmp_path): +def test_accept_unicode_format(tmp_path): path = tmp_path / "config.json" - path.write_text(json.dumps({"provider": "ollama", "math_text_format": "word_equation"}), encoding="utf-8") + path.write_text(json.dumps({"provider": "ollama", "math_text_format": "unicode"}), encoding="utf-8") cfg = load_config(path) - assert cfg.math_text_format == "word_equation" + assert cfg.math_text_format == "unicode" def test_timeout_zero_disables_timeout(tmp_path): diff --git a/tests/test_key_stepper.py b/tests/test_key_stepper.py index cf531d8..3b9579b 100644 --- a/tests/test_key_stepper.py +++ b/tests/test_key_stepper.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from ai_typewriter.key_stepper import KeyStepper -def test_start_types_first_character_immediately_then_hooks_remaining(monkeypatch): +def test_start_installs_hook_without_typing_immediately(monkeypatch): typed = [] hooked = [] @@ -13,37 +13,57 @@ def test_start_types_first_character_immediately_then_hooks_remaining(monkeypatc stepper = KeyStepper("abc") stepper.start() - assert typed == ["a"] + assert typed == [] assert hooked and hooked[0][1] is True - assert stepper.remaining_characters == 2 + assert stepper.remaining_characters == 3 -def test_single_character_response_does_not_install_hook(monkeypatch): +def test_single_character_response_waits_for_key_then_unhooks(monkeypatch): typed = [] - hooks = [] + hook = {} + unhooked = [] monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) - monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hooks.append(callback)) + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook") + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: unhooked.append(value)) stepper = KeyStepper("x") stepper.start() + hook["callback"](SimpleNamespace(event_type="down")) assert typed == ["x"] - assert hooks == [] + assert unhooked == ["hook"] assert stepper.remaining_characters == 0 -def test_key_down_types_next_character_after_auto_first(monkeypatch): +def test_each_key_down_types_next_character(monkeypatch): typed = [] hook = {} monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) - monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.setdefault("callback", callback) or "hook") + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook") monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None) stepper = KeyStepper("ab") stepper.start() hook["callback"](SimpleNamespace(event_type="down")) + hook["callback"](SimpleNamespace(event_type="down")) assert typed == ["a", "b"] assert stepper.remaining_characters == 0 + + +def test_latex_markers_are_typed_literally_character_by_character(monkeypatch): + typed = [] + hook = {} + + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook") + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None) + + stepper = KeyStepper(r"[EQ]\frac{a}{b}[/EQ]") + stepper.start() + for _ in range(len(stepper.text)): + hook["callback"](SimpleNamespace(event_type="down")) + + assert "".join(typed) == r"[EQ]\frac{a}{b}[/EQ]" diff --git a/tests/test_math_format.py b/tests/test_math_format.py index 93def03..d1d547b 100644 --- a/tests/test_math_format.py +++ b/tests/test_math_format.py @@ -1,10 +1,10 @@ import pytest -from ai_typewriter.math_format import format_math_text, split_word_equation_segments +from ai_typewriter.math_format import format_math_text -def test_plain_mode_keeps_linear_math_literal(): - assert format_math_text("z_1 + x^2", "plain") == "z_1 + x^2" +def test_plain_mode_keeps_latex_and_markers_literal(): + assert format_math_text(r"[EQ]\frac{a}{b}[/EQ]", "plain") == r"[EQ]\frac{a}{b}[/EQ]" def test_unicode_mode_converts_indices_exponents_and_symbols(): @@ -13,35 +13,6 @@ def test_unicode_mode_converts_indices_exponents_and_symbols(): assert result == "z₁ + x² + xᵢ₊₁ + α + ∞" -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 "), - ("equation", "z_1 = x + iy"), - ("text", ". Puis "), - ("equation", r"\alpha = 2"), - ("text", "."), - ] - - -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")