# ============================================================ # 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 (.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 # .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 ." } 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 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 \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"