Un agent juridique ne livre pas du texte, il livre des révisions Word
Un juriste ne relit pas un paragraphe réécrit : il relit un diff dans son document. Comment produire des tracked changes OOXML natifs mot à mot, et pourquoi les suggestions Google Docs passent par un détour.
Un agent qui relit un contrat produit sans difficulté un bon paragraphe : mieux formulé, argumenté, aligné sur le référentiel interne. Il le rend sous forme de texte réécrit. Et le juriste ne s’en sert pas.
La raison n’est pas la qualité de la proposition. C’est que le juriste ne travaille pas sur du texte : il travaille sur son document, en mode révision, où chaque mot changé est visible, attribué, acceptable ou refusable d’un clic. Un paragraphe réécrit l’oblige à refaire le diff à la main, puis à reporter les changements. À ce prix, autant écrire soi-même.
En construisant un agent de relecture contractuelle pour une direction juridique, j’en suis arrivé à une conclusion nette : le livrable n’est pas une réponse, c’est un .docx en révisions natives. Si vous branchez un LLM sur des documents de travail, la même règle s’applique.
Ce que le format impose
Word découpe chaque paragraphe en runs, des morceaux de texte au formatage uniforme ; le w:rPr de chaque run porte ce formatage. Une révision suivie (tracked change) n’est pas une couche de diff posée à côté du document : c’est du balisage inline autour de ces runs. w:ins enveloppe les runs insérés, w:del les runs supprimés, et dans un w:del le texte est porté par w:delText, jamais par w:t. Chaque marque porte un w:id, un w:author, un w:date. Schématiquement, un montant en gras remplacé :
<w:del w:id="12" w:author="Agent" w:date="2026-07-02T12:00:00Z">
<w:r><w:rPr><w:b/></w:rPr><w:delText>10 000 €</w:delText></w:r>
</w:del>
<w:ins w:id="13" w:author="Agent" w:date="2026-07-02T12:00:00Z">
<w:r><w:rPr><w:b/></w:rPr><w:t>15 000 €</w:t></w:r>
</w:ins>
Trois contraintes en découlent :
- On écrit dans les runs, pas au-dessus. Une plage à remplacer traverse souvent plusieurs runs. Chaque morceau supprimé doit conserver le
w:rPrde son run d’origine, sinon la révision reformate le contrat en l’annotant. - Les identifiants cohabitent. Un contrat reçu contient déjà les révisions de l’autre partie : les nouveaux
w:idpartent du maximum observé, pas de 1. - L’espace est du contenu.
xml:space="preserve"dès qu’un morceau commence ou finit par un blanc, sinon les espaces sautent aux frontières de révision.
Restituer mot à mot
« Mot à mot » est une exigence métier avant d’être une technique : un juriste veut voir quels mots changent. Quand c’est le code qui calcule le diff, il travaille sur des jetons qui sont soit un mot, soit un bloc de blancs, jamais mélangés. La propriété qui compte est un invariant de reconstruction : conservés + supprimés redonnent l’original caractère pour caractère, conservés + insérés redonnent la proposition. Sans lui, un diff « intelligent » perd une espace ou une apostrophe sans que personne le remarque.
Deux zones modifiées séparées par au plus deux mots inchangés sont fusionnées en un seul remplacement : une révision hachée mot par mot se lit moins bien qu’un bloc remplacé d’une pièce. Puis l’écriture découpe la plage aux frontières de runs :
# Une révision est du balisage inline dans les runs, pas une couche posée à côté.
current_rpr = style_rpr
for segment in mapped_segments:
if segment.kind == "insert":
ins_el = revision_element("w:ins", next_id(), author, date)
ins_el.append(run_with_rpr(current_rpr, "w:t", segment.text))
elements.append(ins_el)
continue
# Découpe aux frontières de runs : chaque morceau garde le w:rPr de son run.
pieces = pieces_by_run(runs, segment.start, segment.end)
if segment.kind == "delete":
del_el = revision_element("w:del", next_id(), author, date)
for rpr, piece in pieces:
# Dans un w:del, le texte se porte en w:delText, jamais en w:t.
del_el.append(run_with_rpr(rpr, "w:delText", piece))
elements.append(del_el)
else:
# keep : le texte inchangé reste hors de toute marque de révision.
for rpr, piece in pieces:
elements.append(run_with_rpr(rpr, "w:t", piece))
# Le texte inséré hérite du style du texte qu'il remplace (après un delete)
# ou du texte conservé adjacent (après un keep) : jamais du style par défaut.
current_rpr = pieces[0][0] if segment.kind == "delete" else pieces[-1][0]
Extrait simplifié. Le point d’entrée du moteur est une fonction pure : aucun appel réseau, aucun LLM, entrée jamais mutée ; à date fixée, deux appels identiques produisent le même XML. Les régressions sont donc testables.
Le modèle propose, le code balise
Qui produit le mot à mot ? Deux façons de le demander au modèle. Soit il marque lui-même ses suppressions et ajouts, et le code vérifie que ces marques retombent exactement sur le passage du contrat. Soit il rend un texte propre, et le code calcule le mot à mot par un diff déterministe. Si des marques existent mais ne retombent pas sur le passage, pas de rattrapage par le diff : le code remplace la plage entière, une suppression et une insertion.
Laquelle demander par défaut ? Je l’ai tranché par une évaluation A/B interne, le 29 juillet 2026, sur 2 contrats et 28 clauses jugées : texte propre 17, marques du modèle 10, une égalité. Deux contrats ne font pas une preuve statistique, mais c’est assez pour fixer un défaut et garder le retour arrière derrière une variable d’environnement.
Un garde-fou commande le reste : sur le chemin des révisions natives, une révision n’est écrite que si le passage cité par le modèle se retrouve exactement, après normalisation, dans le document exporté. Sur ce chemin, une correspondance approchée sert de diagnostic, jamais de plage à modifier. Sans passage source, pas de révision, et rien n’atteint le document sans le clic du juriste.
Ce qui a raté
Le seuil de similarité. Le moteur refusait de marquer mot à mot quand les deux textes se ressemblaient trop peu : sous 0,5 de similarité, repli sur un remplacement en bloc. L’intention était d’éviter les diffs illisibles. Le résultat a été l’inverse : une réécriture légitime à 0,46 a été rendue en bloc et refusée par une juriste, qui voulait voir les mots. J’ai supprimé le seuil : deux textes sans mot commun dégénèrent d’eux-mêmes en un couple suppression / insertion, sans bloquer les cas intermédiaires.
Les suggestions Google Docs. L’API Google Docs ne sait pas créer de suggestions. documents.batchUpdate écrit dans le document, il ne propose pas ; on peut lire les suggestions existantes (suggestedInsertionIds / suggestedDeletionIds via documents.get), pas les écrire. Pour un agent de relecture, c’est le pire écart entre l’intention et l’effet : croire qu’on a proposé quand on a modifié.
Le chemin qui fonctionne est un détour : exporter le Doc en DOCX, y écrire les w:ins / w:del, le renvoyer sur Drive avec conversion. C’est la conversion Google qui transforme les révisions en suggestions natives. Le prix : Google attribue ensuite les suggestions au compte OAuth qui a fait l’envoi, quel que soit le w:author de l’OOXML. L’attribution fine survit dans Word, pas dans Docs. Le détour est devenu un invariant testé : dans le test de contrat du pipeline, tout appel à documents.batchUpdate échoue.
Le mauvais suspect. Sur un document réel, la marge haute revenait écrasée, les en-têtes avaient disparu, les numéros de page réapparaissaient dans le corps. Le moteur de révisions était le coupable évident. Il avait bien un défaut, celui des en-têtes décrit plus bas, mais une fois ce défaut corrigé, il était hors de cause : les tests prouvent qu’il reconduit w:sectPr, marges et références à l’identique. La cause était la conversion PDF → DOCX en amont : un PDF n’a pas de notion d’en-tête, et le convertisseur reconstruit une géométrie plausible. Réponse : un avertissement explicite et la consigne de repartir du .docx d’origine.
Les pièges, dans l’ordre où ils mordent
- En-têtes et pieds de page. Le plus retors : lire
header.paragraphssur une section qui n’en a pas, et python-docx crée silencieusement une partieword/headerN.xmlet unew:headerReference. Inspecter le document change sa mise en page. On testeis_linked_to_previousavant de lire. - Numérotation. Les contrats convertis de PDF n’ont ni
w:pStyleniw:numPr: impossible de numéroter une clause ajoutée depuis la numérotation Word. Elle se déduit du texte : numéro suivant = plus grand numéro parmi les clauses de même niveau + 1, et non leur nombre + 1 (les séquences ont des trous), avec des intercalairesbis/ter. - Champs. Un champ
PAGEdans un pied de page doit rester un champ : le test de non-régression vérifie qu’aucunw:instrTextn’apparaît dans le corps après écriture. - Tableaux. Les paragraphes de cellules ne sont pas dans le corps : sans itérer lignes puis cellules, des clauses entières restent invisibles.
- Frontières de phrase. Quand le modèle propose une phrase complète mais ne cite qu’une partie de la phrase source, la plage est étendue aux frontières de phrase. Si la frontière est ambiguë (un point suivi d’une minuscule, « au sens de l’art. précité »), la révision est écartée plutôt que devinée.
Checklist réutilisable
- Le livrable est le document, pas la réponse. Si l’utilisateur doit reporter les changements à la main, l’agent ne sera pas utilisé.
- Écrire dans le format, pas à côté. Dans Word : balisage inline,
w:delTextdans les suppressions, unw:rPrpar morceau. - Le modèle propose, le code balise. Sa sortie est vérifiée sur le document réel avant d’être écrite.
- Un diff qui reconstruit l’original caractère pour caractère, et un test qui le prouve.
- Un ancrage exact ou rien sur le chemin qui écrit dans le document du client.
- Un test prouve que rien d’autre ne bouge : mise en page, en-têtes, styles et champs sortent identiques.
- Vérifier qu’une API fait ce que son nom promet :
batchUpdatemodifie, il ne suggère pas. - Nommer ce qui est perdu (attribution d’auteur, mise en page) : un avertissement honnête vaut mieux qu’une demi-promesse.
Le format du livrable est une décision d’architecture
Écrire dans un format que l’utilisateur ouvre déjà n’est pas une finition qu’on ajoute après le modèle : c’est ce qui décide si l’agent sera utilisé, et ça se conçoit dès le départ.
Si vous avez un cas d’usage cadré et des utilisateurs prêts à s’en servir, c’est l’objet d’un sprint : je fixe d’abord avec eux le format du livrable, puis je construis l’agent, son jeu d’évaluation et ses garde-fous, jusqu’à la mise en production en quatre à six semaines.