473 lines
19 KiB
PowerShell
473 lines
19 KiB
PowerShell
# ============================================================
|
|
# Omika — Fixtures de référence SentencePiece
|
|
# ============================================================
|
|
# Usage (poste de développement, Python 3 + sentencepiece installés) :
|
|
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles C:\ModelesLangues
|
|
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles .\artifacts -CopierModeles
|
|
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles C:\M -Sortie C:\Temp\fixtures
|
|
#
|
|
# Pour chaque modèle .spm trouvé RÉCURSIVEMENT sous -DossierModeles, écrit un
|
|
# fixture JSON (<nom>.fixtures.json) contenant, pour une série de textes
|
|
# français et espagnols, les identifiants produits par la bibliothèque de
|
|
# RÉFÉRENCE (sentencepiece) et le texte qu'elle reconstruit à partir de ces
|
|
# identifiants.
|
|
#
|
|
# À quoi ça sert : le tokeniseur de src\Omika.App.Local\Services\
|
|
# SentencePieceTokenizer.cs est réécrit à la main (lecture protobuf, Viterbi
|
|
# UNIGRAM, BPE). Son risque n'est pas de lever une exception, c'est de découper
|
|
# LÉGÈREMENT autrement que le modèle — donc de dégrader les traductions sans
|
|
# aucun signal. Ces fixtures sont la seule mesure de cette divergence : elles
|
|
# transforment un soupçon en un test rouge qui nomme la phrase fautive.
|
|
#
|
|
# Le script est idempotent : il réécrit ses propres fixtures (fichiers
|
|
# <nom>.fixtures.json du dossier -Sortie) et ne supprime jamais rien d'autre. Le
|
|
# programme Python temporaire qu'il crée est supprimé par lui, dans un finally.
|
|
# ============================================================
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$DossierModeles,
|
|
|
|
# Dossier des fixtures, relatif à la racine du dépôt s'il ne l'est pas déjà.
|
|
[string]$Sortie = 'tests\fixtures\sentencepiece',
|
|
|
|
# Copie chaque .spm à côté de son fixture : le test .NET cherche le modèle
|
|
# dans le dossier des fixtures, et la copie évite de le faire à la main. Les
|
|
# .spm restent hors du dépôt (artefacts de modèle lourds et sous licence).
|
|
[switch]$CopierModeles
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Racine du dépôt = parent du dossier scripts\.
|
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
|
|
|
function Stop-AvecMessage {
|
|
param([string]$Message)
|
|
|
|
Write-Host ''
|
|
Write-Host "ERREUR : $Message" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
function Format-CheminRelatif {
|
|
param([string]$Chemin)
|
|
|
|
# Les chemins absolus de la machine de développement n'ont rien à faire dans
|
|
# une sortie destinée à être recopiée dans un ticket : on les rend relatifs
|
|
# au dépôt dès que c'est possible.
|
|
if ($Chemin.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
return $Chemin.Substring($RepoRoot.Length).TrimStart('\', '/')
|
|
}
|
|
|
|
return $Chemin
|
|
}
|
|
|
|
function Find-Python {
|
|
# « python » sur Windows peut être l'alias du Microsoft Store, qui n'exécute
|
|
# rien : on vérifie donc que la commande RÉPOND « Python 3 », et pas
|
|
# seulement qu'un fichier de ce nom existe. Le lanceur « py -3 » couvre les
|
|
# installations qui n'exposent pas « python » dans le PATH.
|
|
$candidats = @(
|
|
@{ Nom = 'python'; Arguments = @() },
|
|
@{ Nom = 'py'; Arguments = @('-3') }
|
|
)
|
|
|
|
foreach ($candidat in $candidats) {
|
|
$commande = Get-Command $candidat.Nom -ErrorAction SilentlyContinue
|
|
if (-not $commande) { continue }
|
|
|
|
$arguments = @($candidat.Arguments) + @('--version')
|
|
$sortie = $null
|
|
$code = -1
|
|
|
|
# Windows PowerShell 5.1 transforme la sortie d'ERREUR d'un programme
|
|
# natif en erreur TERMINANTE dès que $ErrorActionPreference vaut Stop :
|
|
# l'alias Microsoft Store de « python », qui écrit sur stderr, ferait
|
|
# donc échouer la détection au lieu d'être simplement écarté. On
|
|
# assouplit la préférence le temps de la capture.
|
|
$preferenceErreur = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
try {
|
|
$sortie = (& $commande.Source @arguments 2>&1 | Out-String)
|
|
$code = $LASTEXITCODE
|
|
}
|
|
catch {
|
|
continue
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $preferenceErreur
|
|
}
|
|
|
|
if ($code -eq 0 -and $sortie -match 'Python 3') {
|
|
return @{ Nom = $candidat.Nom; Chemin = $commande.Source; Arguments = @($candidat.Arguments) }
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
# --- Dossiers -----------------------------------------------------------------
|
|
|
|
if (-not (Test-Path -LiteralPath $DossierModeles -PathType Container)) {
|
|
Stop-AvecMessage "Le dossier de modèles « $DossierModeles » n'existe pas. Indiquez -DossierModeles <dossier contenant les .spm>."
|
|
}
|
|
|
|
if (-not [System.IO.Path]::IsPathRooted($Sortie)) {
|
|
$Sortie = Join-Path $RepoRoot $Sortie
|
|
}
|
|
|
|
$modeles = @(Get-ChildItem -LiteralPath $DossierModeles -Filter '*.spm' -Recurse -File | Sort-Object FullName)
|
|
|
|
if ($modeles.Count -eq 0) {
|
|
Stop-AvecMessage "Aucun fichier .spm trouvé sous « $DossierModeles ». Le vocabulaire d'un pack de langue s'appelle vocab.spm (arborescence langues\{code}\{sens}\vocab.spm) : passez le dossier qui le contient."
|
|
}
|
|
|
|
# --- Interpréteur Python et bibliothèque de référence -------------------------
|
|
# Ces vérifications précèdent la création du dossier de sortie : un script qui
|
|
# échoue faute de Python ne doit laisser AUCUNE trace derrière lui.
|
|
|
|
$python = Find-Python
|
|
if (-not $python) {
|
|
Stop-AvecMessage @"
|
|
Python 3 est introuvable sur ce poste (ni « python », ni « py -3 »).
|
|
Installez Python 3 depuis https://www.python.org/downloads/ puis la bibliothèque
|
|
de référence utilisée par ce script :
|
|
|
|
pip install sentencepiece
|
|
|
|
Aucune fixture n'a été écrite.
|
|
"@
|
|
}
|
|
|
|
$commandePip = "$($python.Chemin) $($python.Arguments -join ' ') -m pip install sentencepiece".Trim()
|
|
|
|
$verification = @($python.Arguments) + @('-c', 'import sentencepiece')
|
|
|
|
# Même précaution que dans Find-Python : l'échec d'import écrit sur stderr, et
|
|
# sous Windows PowerShell 5.1 une erreur native redirigée devient terminante
|
|
# quand $ErrorActionPreference vaut Stop — on veut ici la LIRE pour la traduire.
|
|
$preferenceErreur = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
try {
|
|
$sortieImport = (& $python.Chemin @verification 2>&1 | Out-String).Trim()
|
|
$codeImport = $LASTEXITCODE
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $preferenceErreur
|
|
}
|
|
|
|
if ($codeImport -ne 0) {
|
|
Stop-AvecMessage @"
|
|
La bibliothèque « sentencepiece » n'est pas installée pour l'interpréteur détecté ($($python.Chemin)).
|
|
Installez-la :
|
|
|
|
$commandePip
|
|
|
|
Erreur renvoyée par Python :
|
|
|
|
$sortieImport
|
|
|
|
Aucune fixture n'a été écrite.
|
|
"@
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $Sortie)) {
|
|
New-Item -ItemType Directory -Path $Sortie -Force | Out-Null
|
|
}
|
|
|
|
# --- Programme Python embarqué ------------------------------------------------
|
|
# Le programme est ÉCRIT DANS UN FICHIER TEMPORAIRE puis supprimé : l'humain n'a
|
|
# qu'un seul script à connaître, et il ne peut pas désynchroniser un second
|
|
# fichier oublié dans le dépôt.
|
|
#
|
|
# ATTENTION ENCODAGE : le programme contient des textes accentués, arabes et
|
|
# cyrilliques. Il est donc écrit explicitement en UTF-8 SANS BOM (voir l'écriture
|
|
# ci-dessous) ; il en va de même du JSON qu'il produit, un BOM en tête de fichier
|
|
# faisant échouer les lecteurs JSON stricts.
|
|
#
|
|
# Corollaire pour CE fichier : le script doit lui-même être enregistré en UTF-8
|
|
# AVEC BOM, sinon Windows PowerShell 5.1 (qui lit l'UTF-8 sans BOM comme de
|
|
# l'ANSI) transformerait les textes de référence en caractères parasites — et un
|
|
# fixture de textes corrompus ne mesurerait plus rien.
|
|
|
|
$ProgrammePython = @'
|
|
# -*- coding: utf-8 -*-
|
|
"""Écrit le fixture JSON d'un modèle SentencePiece .spm.
|
|
|
|
Usage : python <ce_fichier.py> <modele.spm> <sortie.fixtures.json>
|
|
|
|
Le fixture décrit le découpage de la bibliothèque de RÉFÉRENCE, jamais celui de
|
|
l'implémentation maison : `ids` vient de EncodeAsIds et `decode` de DecodeIds
|
|
appliqué à CES identifiants. Le fixture est donc auto-cohérent, et le test .NET
|
|
compare le tokeniseur C# à la référence au lieu de le comparer à une
|
|
transcription manuelle, qui figerait une erreur de recopie.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from sentencepiece import sentencepiece as spm
|
|
|
|
|
|
def lire_varint(donnees, position):
|
|
"""Lit un entier variable protobuf : (valeur, position suivante)."""
|
|
valeur = 0
|
|
decalage = 0
|
|
while True:
|
|
octet = donnees[position]
|
|
position += 1
|
|
valeur |= (octet & 0x7F) << decalage
|
|
if not octet & 0x80:
|
|
return valeur, position
|
|
decalage += 7
|
|
|
|
|
|
def champs(donnees):
|
|
"""Parcourt les champs d'un message protobuf : (numéro, type de fil, valeur)."""
|
|
position = 0
|
|
while position < len(donnees):
|
|
cle, position = lire_varint(donnees, position)
|
|
champ, fil = cle >> 3, cle & 0x07
|
|
|
|
if fil == 0:
|
|
valeur, position = lire_varint(donnees, position)
|
|
elif fil == 1:
|
|
valeur, position = donnees[position:position + 8], position + 8
|
|
elif fil == 2:
|
|
longueur, position = lire_varint(donnees, position)
|
|
valeur, position = donnees[position:position + longueur], position + longueur
|
|
elif fil == 5:
|
|
valeur, position = donnees[position:position + 4], position + 4
|
|
else:
|
|
raise ValueError("type de fil protobuf non pris en charge : %d" % fil)
|
|
|
|
yield champ, fil, valeur
|
|
|
|
|
|
def spec_normalisation_a_la_main(chemin):
|
|
"""Repli sans dépendance : lit normalizer_spec (champ 3 du ModelProto).
|
|
|
|
`sentencepiece_model_pb2` n'est pas toujours importable (il dépend du runtime
|
|
protobuf, absent de certaines installations minimales) ; or ces quatre
|
|
réglages décident du découpage — les deviner fausserait tout le fixture. Les
|
|
valeurs par défaut reproduites ici sont celles du proto2 de SentencePiece :
|
|
un champ absent vaut True.
|
|
"""
|
|
with open(chemin, "rb") as fichier:
|
|
donnees = fichier.read()
|
|
|
|
for champ, fil, valeur in champs(donnees):
|
|
if champ != 3 or fil != 2: # ModelProto.normalizer_spec
|
|
continue
|
|
|
|
nom, prefixe, superflus, echapper = "nmt_nfkc", True, True, True
|
|
for numero, type_fil, contenu in champs(valeur):
|
|
if numero == 1 and type_fil == 2:
|
|
nom = contenu.decode("utf-8") or nom
|
|
elif numero == 3 and type_fil == 0:
|
|
prefixe = bool(contenu)
|
|
elif numero == 4 and type_fil == 0:
|
|
superflus = bool(contenu)
|
|
elif numero == 5 and type_fil == 0:
|
|
echapper = bool(contenu)
|
|
|
|
return nom, prefixe, superflus, echapper
|
|
|
|
return "nmt_nfkc", True, True, True
|
|
|
|
|
|
def spec_normalisation(chemin):
|
|
"""Réglages de normalisation enregistrés dans le modèle, table lue en priorité."""
|
|
try:
|
|
from sentencepiece import sentencepiece_model_pb2
|
|
|
|
modele = sentencepiece_model_pb2.ModelProto()
|
|
with open(chemin, "rb") as fichier:
|
|
modele.ParseFromString(fichier.read())
|
|
|
|
spec = modele.normalizer_spec
|
|
return (
|
|
spec.name or "nmt_nfkc",
|
|
bool(spec.add_dummy_prefix),
|
|
bool(spec.remove_extra_whitespaces),
|
|
bool(spec.escape_whitespaces),
|
|
)
|
|
except Exception:
|
|
return spec_normalisation_a_la_main(chemin)
|
|
|
|
|
|
def version_sentencepiece():
|
|
"""Version de la référence, écrite dans le fixture : elle DATE la comparaison.
|
|
|
|
Sans elle, une divergence constatée dans six mois ne se distingue pas d'un
|
|
changement de version de la bibliothèque.
|
|
"""
|
|
import sentencepiece
|
|
|
|
version = getattr(sentencepiece, "__version__", None)
|
|
if not version:
|
|
version = getattr(getattr(sentencepiece, "sentencepiece", None), "__version__", None)
|
|
|
|
return str(version) if version else "inconnue"
|
|
|
|
|
|
def cas_de_reference():
|
|
"""Textes comparés, avec leur étiquette.
|
|
|
|
Les deux langues visées par les packs sont couvertes, plus les entrées qui
|
|
font diverger un tokeniseur réécrit à la main : espaces de bord et doubles,
|
|
chaîne vide, chiffres et ponctuation, casse mêlée, alphabets non latins, et
|
|
une phrase longue — c'est là, et nulle part ailleurs, que se voit le coût du
|
|
pire cas d'un Viterbi naïf.
|
|
"""
|
|
longue = (
|
|
"La traduction hors ligne doit rester fidèle même sur une phrase très "
|
|
"longue, parce que c'est exactement le genre d'entrée qui fait diverger "
|
|
"une implémentation réécrite à la main. "
|
|
) * 12
|
|
|
|
return [
|
|
("francais-accents", "Bonjour le monde, l'été dernier j'ai mangé une crêpe à Nîmes."),
|
|
("espagnol-accents", "¿Qué tal? ¡Hola, señor! El niño pequeño añadió más café."),
|
|
("espaces-bords-et-doubles", " Bonjour le monde "),
|
|
("chaine-vide", ""),
|
|
("espaces-seulement", " "),
|
|
("chiffres-et-ponctuation", "Le total est 1 234,56 € (soit 42 %), lundi 03/02/2025 ; OK ?"),
|
|
("phrase-tres-longue", longue.strip()),
|
|
("casse-melangee", "OMIKA Omika omika BonJour LE MONDE Traduction HORS LIGNE"),
|
|
("arabe", "مرحبا بالعالم، كيف حالك اليوم؟"),
|
|
("cyrillique", "Здравствуйте, мир! Как ваши дела сегодня?"),
|
|
("nouvelle-ligne-et-tabulation", "Bonjour\nle\tmonde"),
|
|
]
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 3:
|
|
sys.stderr.write("Usage : python <programme> <modele.spm> <sortie.fixtures.json>\n")
|
|
return 2
|
|
|
|
chemin_modele, chemin_sortie = sys.argv[1], sys.argv[2]
|
|
|
|
processeur = spm.SentencePieceProcessor(model_file=chemin_modele)
|
|
|
|
nom, prefixe, superflus, echapper = spec_normalisation(chemin_modele)
|
|
|
|
cas = []
|
|
for etiquette, texte in cas_de_reference():
|
|
try:
|
|
ids = processeur.EncodeAsIds(texte)
|
|
decode = processeur.DecodeIds(ids)
|
|
except Exception as erreur:
|
|
# Le nom du cas est indispensable : sans lui, un modèle exotique
|
|
# ferait échouer la génération sans dire sur quelle entrée.
|
|
sys.stderr.write("Cas « %s » en échec : %s\n" % (etiquette, erreur))
|
|
raise
|
|
|
|
cas.append({"texte": texte, "ids": [int(i) for i in ids], "decode": decode})
|
|
|
|
fixture = {
|
|
"modele": os.path.basename(chemin_modele),
|
|
"sentencepieceVersion": version_sentencepiece(),
|
|
"vocabSize": int(processeur.get_piece_size()),
|
|
"addDummyPrefix": bool(prefixe),
|
|
"removeExtraWhitespaces": bool(superflus),
|
|
"escapeWhitespaces": bool(echapper),
|
|
"normalizerName": nom,
|
|
"cas": cas,
|
|
}
|
|
|
|
# ensure_ascii=False : les textes accentués, arabes et cyrilliques restent
|
|
# lisibles dans le JSON, ce qui rend une divergence lisible à l'œil.
|
|
# encoding="utf-8" SANS BOM (Python n'en écrit jamais par défaut) : un BOM
|
|
# ferait échouer les lecteurs JSON stricts.
|
|
with open(chemin_sortie, "w", encoding="utf-8", newline="\n") as fichier:
|
|
json.dump(fixture, fichier, ensure_ascii=False, indent=2)
|
|
fichier.write("\n")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
'@
|
|
|
|
$cheminProgramme = Join-Path ([System.IO.Path]::GetTempPath()) ('omika-fixtures-sentencepiece-' + (New-Guid).ToString('N') + '.py')
|
|
[System.IO.File]::WriteAllText($cheminProgramme, $ProgrammePython, (New-Object System.Text.UTF8Encoding($false)))
|
|
|
|
$lignes = New-Object System.Collections.Generic.List[object]
|
|
$copies = New-Object System.Collections.Generic.List[string]
|
|
|
|
try {
|
|
foreach ($modele in $modeles) {
|
|
$nomFixture = $modele.BaseName + '.fixtures.json'
|
|
$cheminFixture = Join-Path $Sortie $nomFixture
|
|
|
|
Write-Host ("Génération du fixture de « {0} » ({1} Ko)…" -f $modele.Name, [int]($modele.Length / 1KB))
|
|
|
|
$arguments = @($python.Arguments) + @($cheminProgramme, $modele.FullName, $cheminFixture)
|
|
& $python.Chemin @arguments
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Stop-AvecMessage "La génération du fixture de « $($modele.FullName) » a échoué (code $LASTEXITCODE). Aucune fixture n'a été écrite pour ce modèle ; les messages de la bibliothèque sentencepiece ci-dessus indiquent la cause (fichier tronqué, modèle d'un type non géré…)."
|
|
}
|
|
|
|
# Relecture du fixture écrit : c'est ce que lira le test .NET, et c'est
|
|
# donc cette relecture qui alimente le tableau récapitulatif.
|
|
$fixture = Get-Content -LiteralPath $cheminFixture -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
if (-not $fixture.vocabSize -or -not $fixture.cas) {
|
|
Stop-AvecMessage "Le fixture « $(Format-CheminRelatif $cheminFixture) » vient d'être écrit mais ne contient ni taille de vocabulaire ni cas de test : le fichier est inexploitable, ne le commitez pas."
|
|
}
|
|
|
|
if ($CopierModeles) {
|
|
$destination = Join-Path $Sortie $modele.Name
|
|
|
|
if (Test-Path -LiteralPath $destination) {
|
|
# Deux modèles de même nom venant de dossiers différents : on ne
|
|
# remplace jamais un .spm existant par un autre (le fixture déjà
|
|
# écrit pourrait décrire le premier).
|
|
if ((Get-FileHash -LiteralPath $modele.FullName).Hash -ne (Get-FileHash -LiteralPath $destination).Hash) {
|
|
Write-Host " ATTENTION : « $(Format-CheminRelatif $destination) » existe avec un contenu différent — copie ignorée, copiez le bon modèle à la main." -ForegroundColor Yellow
|
|
}
|
|
}
|
|
else {
|
|
Copy-Item -LiteralPath $modele.FullName -Destination $destination
|
|
$copies.Add($modele.Name)
|
|
}
|
|
}
|
|
|
|
$lignes.Add([pscustomobject]@{
|
|
'Modèle' = $modele.Name
|
|
'Vocabulaire' = [int]$fixture.vocabSize
|
|
'Cas' = @($fixture.cas).Count
|
|
'Fixture' = Format-CheminRelatif $cheminFixture
|
|
})
|
|
}
|
|
}
|
|
finally {
|
|
# Rien d'autre que ce fichier temporaire n'est supprimé par ce script.
|
|
Remove-Item -LiteralPath $cheminProgramme -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host 'Résumé :'
|
|
$lignes | Format-Table -AutoSize | Out-String | Write-Host
|
|
|
|
Write-Host ("{0} fixture(s) écrite(s) dans {1}." -f $lignes.Count, (Format-CheminRelatif $Sortie))
|
|
|
|
if ($CopierModeles) {
|
|
if ($copies.Count -gt 0) {
|
|
Write-Host ("{0} modèle(s) copié(s) à côté de leur fixture : {1}." -f $copies.Count, ($copies -join ', '))
|
|
}
|
|
}
|
|
else {
|
|
Write-Host "Le test .NET cherche le .spm DANS le dossier des fixtures : relancez avec -CopierModeles, ou copiez-le à la main (il n'est pas versionné)."
|
|
}
|
|
|
|
Write-Host "Ensuite : dotnet test tests\Omika.Tests\Omika.Tests.csproj --filter FullyQualifiedName~SentencePieceFixturesTests"
|