Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
# ============================================================
|
||||
# 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"
|
||||
Reference in New Issue
Block a user