Return to plain LaTeX typing mode

This commit is contained in:
Hermes Agent
2026-09-12 12:17:07 +02:00
parent 7bd423452d
commit d07bed60e6
10 changed files with 90 additions and 191 deletions
+19 -23
View File
@@ -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
+11
View File
@@ -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]."
}
-11
View File
@@ -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]."
}
+7 -20
View File
@@ -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)
+3 -3
View File
@@ -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]
+9 -39
View File
@@ -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")
+6 -51
View File
@@ -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
+3 -3
View File
@@ -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):
+29 -9
View File
@@ -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]"
+3 -32
View File
@@ -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")