erplibre/script/todo/assistant/chat.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

177 lines
6.8 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La conversation : les tours, en mémoire, et les commandes qui la pilotent.
L'historique vit dans cet objet et NULLE PART ailleurs : rien ici n'ouvre un
fichier. Il meurt avec le menu, et `/save` — dont l'écriture appartient au
menu — est le seul moyen d'en garder une trace, ce qui doit se dire AVANT la
conversation qui méritait d'être gardée.
Deux subtilités que la forme de la boucle impose :
**`keeps_history`.** Une session `claude` tient son histoire de son côté. La
conversation demande donc au backend s'il la garde et, dans ce cas, n'envoie
que le nouveau tour : rejouer l'historique local doublerait chaque échange et
ferait payer deux fois les mêmes jetons.
**Toute commande porte une barre oblique initiale, et aucun nombre nu n'est
une commande.** Une question collée sur plusieurs lignes devient autant de
tours, et une ligne collée valant `0` déclencherait une action de menu si les
nombres décidaient. `parse_command` ne reconnaît donc qu'un mot d'une seule
barre oblique et rend tout le reste comme du texte.
Les valeurs de `COMMANDS` SONT les clés i18n : `t()` rend une clé absente
inchangée, donc une commande non traduite s'affiche en anglais correct. La
traduction se fait à l'affichage, dans le menu.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from script.todo.assistant.backends import BackendError, Interrupted
# La forme d'une commande : une barre oblique, puis des lettres ou « ? », et
# rien d'autre. Une seconde barre oblique fait de la ligne un chemin, et une
# question qui commence par un chemin reste une question.
COMMAND_SHAPE = re.compile(r"^/[a-z?]{1,12}$")
# L'ordre est celui de l'affichage de `/?` : partir, repartir, changer, voir.
COMMANDS: dict[str, str] = {
"/q": "back to the menu",
"/new": "clear the history (same server, same tool)",
"/gpt": "change tool, history kept",
"/srv": (
"change server, history CLEARED — the model is no longer the same"
),
"/ctx": "show again what was sent",
"/m": 'multi-line entry, end with a single "." line',
"/save": "write the conversation to a file",
"/?": "list the commands",
}
@dataclass
class Turn:
"""Un tour : qui parle, ce qui a été dit, et si la réponse a été coupée.
`role` vaut `user`, `assistant` ou `error`. Un tour `error` n'entre jamais
dans l'historique : il rapporte une panne, pas un échange.
"""
role: str
text: str
interrupted: bool = False
def parse_command(line: str) -> tuple[str | None, str]:
"""La commande d'une ligne saisie, et ce qui la suit.
Rend `(None, line)` — la ligne INTACTE, collage compris — pour tout ce qui
n'est pas de la forme d'une commande : l'appelant traite cela comme du
texte à envoyer. Rend sinon `(commande, reste)`, la commande en
minuscules ; l'appelant confronte la commande à `COMMANDS` et nomme
celle qu'il ne connaît pas plutôt que de l'envoyer au modèle.
"""
texte = (line or "").strip()
if not texte.startswith("/"):
return None, line
tete, _, reste = texte.partition(" ")
tete = tete.lower()
if not COMMAND_SHAPE.match(tete):
return None, line
return tete, reste.strip()
class Conversation:
"""Les tours d'un échange avec un backend, en mémoire seulement.
`system` prime sur celui du gpt, ce qui permet au menu de composer une
invite système sans toucher au catalogue. `last_sent` porte les messages
du dernier envoi — c'est ce que `/ctx` réaffiche, y compris après une
panne, parce que ce qui est parti est parti.
L'historique ne garde que les échanges qui portent du texte : une panne
n'y laisse rien, et retaper la question EST la reprise.
"""
def __init__(self, backend, *, gpt=None, system=None):
self.backend = backend
self.gpt = gpt
if system is None:
system = getattr(gpt, "system", "") or ""
self.system = system
self.turns: list[Turn] = []
self.last_sent: list[dict] = []
self.last_meta: dict = {}
def _messages(self, text) -> list[dict]:
"""Les messages du prochain envoi.
Un backend qui garde son histoire ne reçoit que le nouveau tour — ni
l'historique, ni l'invite système, qu'il porte déjà de son côté.
"""
if getattr(self.backend, "keeps_history", False):
return [{"role": "user", "content": text}]
messages = []
if self.system:
messages.append({"role": "system", "content": self.system})
for tour in self.turns:
messages.append({"role": tour.role, "content": tour.text})
messages.append({"role": "user", "content": text})
return messages
def ask(self, text, *, on_chunk=None) -> Turn:
"""Un tour de conversation, et le tour rendu par le backend.
Rend un tour `error` quand l'envoi échoue : le message tient sur une
ligne et l'historique reste tel qu'il était. Une coupure rend un tour
marqué `interrupted` et garde le texte déjà reçu — il a été payé.
"""
question = Turn("user", text)
messages = self._messages(text)
self.last_sent = messages
try:
reponse, faits = self.backend.send(messages, on_chunk=on_chunk)
except (Interrupted, KeyboardInterrupt) as coupure:
partiel = getattr(coupure, "partial", "")
self.last_meta = getattr(coupure, "meta", {}) or {}
tour = Turn("assistant", partiel, interrupted=True)
if partiel:
self.turns.extend((question, tour))
return tour
except BackendError as panne:
return Turn("error", str(panne))
self.last_meta = faits or {}
tour = Turn("assistant", reponse)
self.turns.extend((question, tour))
return tour
def reset(self) -> int:
"""Vide l'historique et rend le nombre de tours jetés.
Chaque question et chaque réponse compte pour un tour : un échange
complet en vaut deux.
"""
jetes = len(self.turns)
self.turns = []
self.last_sent = []
self.last_meta = {}
return jetes
def transcript(self) -> str:
"""La conversation en Markdown, prête pour `/save`.
Porte les tours et le nom du gpt, pas l'invite système : `/ctx`
montre ce qui est parti, ce fichier montre ce qui s'est dit.
"""
lignes = []
nom = getattr(self.gpt, "name", "")
if nom:
lignes += [f"# {nom}", ""]
for tour in self.turns:
marque = " (interrupted)" if tour.interrupted else ""
lignes += [f"## {tour.role}{marque}", tour.text, ""]
return "\n".join(lignes)