# ============================================================ # Omika — Export des packs de langue (modèles ONNX OPUS-MT) # ============================================================ # Usage (poste de développement : Internet, Python 3, ~30 Go de disque libre) : # # .\scripts\Exporter-Packs-Langues.ps1 -Simulation # le PLAN, rien n'est telecharge # .\scripts\Exporter-Packs-Langues.ps1 -SansExporter # quelles langues SONT livrables (rien n'est telecharge) # .\scripts\Exporter-Packs-Langues.ps1 # toutes les langues de la table # .\scripts\Exporter-Packs-Langues.ps1 -Codes es,pt,uk,ru # .\scripts\Exporter-Packs-Langues.ps1 -Zone Est,Caucase # .\scripts\Exporter-Packs-Langues.ps1 -RacinePacks D:\PacksLangues # # PREREQUIS (une seule fois, sur le poste qui telecharge) : # pip install --user "optimum[onnxruntime]" transformers sentencepiece # C'est cet outil qui telecharge le modele PyTorch puis le convertit en ONNX. # Sans lui le script s'arrete immediatement, sans rien telecharger. Il est # cherche dans le PATH, PUIS dans le dossier des scripts Python — car avec le # Python du Microsoft Store « pip install » reussit alors que « optimum-cli » # reste introuvable (ce dossier n'est pas dans le PATH). # # CE QUE FAIT LE SCRIPT # 1. Pour chaque langue, verifie que les DEUX modeles bilingues existent : # Helsinki-NLP/opus-mt--fr (langue -> francais) et # Helsinki-NLP/opus-mt-fr- (francais -> langue). La verification se # fait sur le CATALOGUE public de l'auteur (une requete), pas par un appel # par modele : voir Get-CatalogueHelsinkiNLP pour les trois raisons. # 2. Exporte chacun en ONNX avec optimum-cli. # 3. Ne conserve QUE les fichiers utiles au pack (encoder_model.onnx, # decoder_model.onnx, *.spm, vocab.json, config.json) : # decoder_with_past_model.onnx est ecarte — l'application l'ignore, mais # s'il etait livre il serait telecharge par chaque telephone (~120 Mo de # donnees mobiles pour rien, par langue). # # vocab.json et config.json sont OBLIGATOIRES (mesure sur deux modeles # reels) : le .spm numerote ses pieces autrement que le modele (▁Ho = 5293 # cote .spm contre 584 cote modele) et il ne contient ni l'amorce du # decodeur ni sa fin. Sans ces deux fichiers, le modele recoit un autre # texte que celui dicte et produit du charabia, sans lever d'erreur. # 3b. Une langue sans paire bilingue complete peut etre servie par UN SEUL # modele multilingue de sa famille (opus-mt-tc-big-itc-itc couvre it, es, # pt, ca, gl et ro dans les deux sens). Ses poids (~1,15 Go) sont ecrits # UNE fois dans \commun\ ; chaque sens ne recoit qu'un modele.json de # quelques dizaines d'octets declarant son JETON DE CIBLE (>>fra<< pour # l'aller, >>por<< pour le retour), que l'application place elle-meme en # tete du texte. Le telephone ne telecharge donc ces poids qu'UNE fois. # 4. Ecrit l'arborescence attendue par l'API : \\\. # 5. Ecrit un rapport : RAPPORT-LANGUES.md (packs construits, paires # INDISPONIBLES, packs partiels, poids total). # 6. Avec -SansExporter : s'arrete apres l'etape 1 et ecrit le rapport. Repond # a « quelles langues sont manquantes ? » sans rien telecharger. # # CE QUE LE SCRIPT NE FAIT PAS # - Le manifeste : il contient la taille et l'empreinte SHA-256 des fichiers # REELLEMENT presents sur le serveur. Il se genere LA OU SONT LES MODELES # (infra\deploy\New-OmikaLanguesManifeste.ps1), jamais ici. # - Le transfert vers le serveur, ni la validation de la qualite de traduction # (voir docs\VALIDATION_TRADUCTION_ONNX.md §2.5). # # POURQUOI UN BILINGUE EN PRIORITE, UN FAMILIAL EN REPLI # Un modele bilingue (~300 Mo par sens) est plus leger que le familial # (~1,15 Go pour 11 langues, deux sens compris) : il est employe des qu'il # existe. Le familial prend le relais quand une paire manque, ou quand le # bilingue ne s'exporte pas. Il ne sait ecrire QUE la langue que le pack lui # demande : sans le jeton de cible declare par modele.json, il produirait sa # langue par defaut — du texte parfaitement lisible dans la mauvaise langue, # sans le moindre signe d'erreur. C'est pourquoi ce jeton est ecrit ici. # ============================================================ [CmdletBinding()] param( # Dossier de sortie des packs. Peut etre directement la racine des modeles # du serveur si le script y est lance (Langues__RootPath). [string]$RacinePacks = 'packs', # Cache de telechargement de HuggingFace. Par defaut : \.hf-cache, # pour que TOUT (packs + cache) reste sur le meme disque et se supprime d'un # seul geste. Si HF_HOME est deja defini dans l'environnement, il est respecte. [string]$CacheHuggingFace, # Conserve le cache apres un export reussi. Par defaut il est supprime : le # modele PyTorch telecharge (~300 Mo par sens) n'est plus utile une fois # l'export ONNX reussi, et le garder double l'espace disque necessaire. # Le conserver n'a d'interet que pour enchainer plusieurs -Forcer. [switch]$GarderCacheHuggingFace, # Sous-ensemble de langues (codes). Par defaut : toute la table ci-dessous. [string[]]$Codes, # Filtre par zone geographique : Ouest, Sud, Nord, Centre, Baltes, Est, # Caucase, AsieCentrale, Divers. [string[]]$Zone, # Interpreteur Python (celui qui porte optimum). [string]$Python = 'python', # Commande de l'outil d'export (installee par « pip install optimum[onnxruntime] »). [string]$OptimumCli = 'optimum-cli', # Affiche le plan complet SANS reseau, sans Python, sans rien ecrire. # A lancer en premier : c'est la seule facon de voir le volume avant de le payer. [switch]$Simulation, # Reexporte une langue deja presente (par defaut elle est ignoree). [switch]$Forcer, # Construit un pack meme si UNE SEULE des deux directions existe. Par defaut # la langue est ignoree : un pack a une seule direction laisse l'application # sans modele pour l'autre usage (dictee OU lecture). [switch]$InclureDirectionsPartielles, # Ne consulte pas HuggingFace : tente l'export directement. Utile hors ligne, # mais on perd la distinction « paire inexistante » / « reseau injoignable ». [switch]$SansVerifierHuggingFace, # N'exporte RIEN : verifie seulement quelles paires existent reellement sur # HuggingFace, puis ecrit le rapport. Repond en une minute a « quelles langues # sont manquantes ? » sans telecharger 30 Go. Les langues introuvables sont # listees dans « Paires indisponibles », les autres dans « Paires disponibles ». [switch]$SansExporter ) $ErrorActionPreference = 'Stop' $RepoRoot = Split-Path -Parent $PSScriptRoot # ============================================================ # Table des langues # ============================================================ # Le francais est le PIVOT de l'application : il n'est pas dans la table (un # pack « fr » n'aurait aucun sens). # # « Alias » : codes de modele essayes si le code principal n'a pas de paire avec # le francais (Helsinki-NLP utilise parfois un code different, ex. no/nb). # # Modifier cette table est sans risque : le script verifie chaque paire sur # HuggingFace avant de telecharger quoi que ce soit, et signale les absentes. $Langues = @( # --- Europe de l'Ouest --- @{ Code = 'en'; Libelle = 'Anglais'; Zone = 'Ouest' } @{ Code = 'de'; Libelle = 'Allemand'; Zone = 'Ouest' } @{ Code = 'nl'; Libelle = 'Néerlandais'; Zone = 'Ouest' } @{ Code = 'lb'; Libelle = 'Luxembourgeois'; Zone = 'Ouest' } # --- Europe du Sud --- @{ Code = 'es'; Libelle = 'Espagnol'; Zone = 'Sud' } @{ Code = 'it'; Libelle = 'Italien'; Zone = 'Sud' } @{ Code = 'pt'; Libelle = 'Portugais'; Zone = 'Sud' } @{ Code = 'el'; Libelle = 'Grec'; Zone = 'Sud' } @{ Code = 'mt'; Libelle = 'Maltais'; Zone = 'Sud' } @{ Code = 'ca'; Libelle = 'Catalan'; Zone = 'Sud' } @{ Code = 'gl'; Libelle = 'Galicien'; Zone = 'Sud' } @{ Code = 'eu'; Libelle = 'Basque'; Zone = 'Sud' } @{ Code = 'sq'; Libelle = 'Albanais'; Zone = 'Sud' } # --- Europe du Nord --- @{ Code = 'da'; Libelle = 'Danois'; Zone = 'Nord' } @{ Code = 'sv'; Libelle = 'Suédois'; Zone = 'Nord' } @{ Code = 'no'; Libelle = 'Norvégien'; Zone = 'Nord'; Alias = @('nb', 'nn') } @{ Code = 'fi'; Libelle = 'Finnois'; Zone = 'Nord' } @{ Code = 'is'; Libelle = 'Islandais'; Zone = 'Nord' } @{ Code = 'ga'; Libelle = 'Irlandais'; Zone = 'Nord' } @{ Code = 'cy'; Libelle = 'Gallois'; Zone = 'Nord' } # --- Europe centrale --- @{ Code = 'pl'; Libelle = 'Polonais'; Zone = 'Centre' } @{ Code = 'cs'; Libelle = 'Tchèque'; Zone = 'Centre' } @{ Code = 'sk'; Libelle = 'Slovaque'; Zone = 'Centre' } @{ Code = 'hu'; Libelle = 'Hongrois'; Zone = 'Centre' } @{ Code = 'ro'; Libelle = 'Roumain'; Zone = 'Centre' } @{ Code = 'sl'; Libelle = 'Slovène'; Zone = 'Centre' } @{ Code = 'hr'; Libelle = 'Croate'; Zone = 'Centre' } @{ Code = 'sr'; Libelle = 'Serbe'; Zone = 'Centre'; Alias = @('hbs') } @{ Code = 'bs'; Libelle = 'Bosniaque'; Zone = 'Centre' } @{ Code = 'mk'; Libelle = 'Macédonien'; Zone = 'Centre' } @{ Code = 'bg'; Libelle = 'Bulgare'; Zone = 'Centre' } # --- Pays baltes --- @{ Code = 'lt'; Libelle = 'Lituanien'; Zone = 'Baltes' } @{ Code = 'lv'; Libelle = 'Letton'; Zone = 'Baltes' } @{ Code = 'et'; Libelle = 'Estonien'; Zone = 'Baltes' } # --- Est (pays de l'Est europeen) --- @{ Code = 'ru'; Libelle = 'Russe'; Zone = 'Est' } @{ Code = 'uk'; Libelle = 'Ukrainien'; Zone = 'Est' } @{ Code = 'be'; Libelle = 'Biélorusse'; Zone = 'Est' } # --- Caucase --- @{ Code = 'ka'; Libelle = 'Géorgien'; Zone = 'Caucase' } @{ Code = 'hy'; Libelle = 'Arménien'; Zone = 'Caucase' } @{ Code = 'az'; Libelle = 'Azéri'; Zone = 'Caucase' } @{ Code = 'ce'; Libelle = 'Tchétchène'; Zone = 'Caucase' } @{ Code = 'os'; Libelle = 'Ossète'; Zone = 'Caucase' } # --- Asie centrale et voisins --- @{ Code = 'kk'; Libelle = 'Kazakh'; Zone = 'AsieCentrale' } @{ Code = 'uz'; Libelle = 'Ouzbek'; Zone = 'AsieCentrale' } @{ Code = 'ky'; Libelle = 'Kirghize'; Zone = 'AsieCentrale' } @{ Code = 'tg'; Libelle = 'Tadjik'; Zone = 'AsieCentrale' } @{ Code = 'tk'; Libelle = 'Turkmène'; Zone = 'AsieCentrale' } @{ Code = 'mn'; Libelle = 'Mongol'; Zone = 'AsieCentrale' } # --- Divers (langues tres presentes dans le contexte Omika) --- @{ Code = 'tr'; Libelle = 'Turc'; Zone = 'Divers' } @{ Code = 'tt'; Libelle = 'Tatar'; Zone = 'Divers' } @{ Code = 'ar'; Libelle = 'Arabe'; Zone = 'Divers' } ) # Estimation HAUTE par sens (encoder + decoder fp32 d'un modele Marian), pour le # plan de -Simulation. Le poids reel est mesure a l'export et ecrit au rapport. $EstimationMoParSens = 300 $SensCanoniques = @('versFrancais', 'depuisFrancais') # Dossier PARTAGE entre les deux sens d'une langue (poids d'un modele familial). # L'application le cherche APRES le dossier du sens : un pack bilingue garde la # priorite s'il en existe un. $DossierCommun = 'commun' # Metadonnees d'un sens (`modele.json`) : le jeton de cible d'un modele # multilingue. Format lu par l'application (ModelePackMetadonnees) : # { "version": 1, "modeleSource": "…", "famille": "itc", "jetonCible": ">>fra<<" } $FichierMetadonnees = 'modele.json' # Jeton de cible du francais : il est identique dans TOUS les modeles familiaux # (le francais etant le pivot de l'application), donc ecrit une seule fois ici. $JetonVersFrancais = '>>fra<<' # Modeles FAMILIAUX : un seul modele multilingue couvre TOUTES les langues de sa # famille, dans les deux sens, via un jeton de cible place en tete du texte. # - il ne sert QUE si au moins une direction n'a pas de paire bilingue ; # - ses poids sont ecrits UNE fois dans \commun\ (partage entre les deux # sens), et chaque sens ne recoit que son modele.json ; # - jeton de l'aller : >>fra<< (constante ci-dessus) ; jeton du retour : # le code de la langue dans la nomenclature du modele (>>por<<, >>ita<<…), # qui n'est pas toujours le code ISO (galicien : >>glg<<, roumain : >>ron<<). # Les jetons ont ete releves dans le vocab.json du modele (ils n'existent pas # dans le .spm) et verifies par une traduction reelle. $ModelesFamiliaux = @( @{ Code = 'es'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>spa<<' } @{ Code = 'it'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>ita<<' } @{ Code = 'pt'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>por<<' } @{ Code = 'ca'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>cat<<' } @{ Code = 'gl'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>glg<<' } @{ Code = 'ro'; Modele = 'Helsinki-NLP/opus-mt-tc-big-itc-itc'; Famille = 'itc'; JetonDepuis = '>>ron<<' } ) # Fichiers d'un pack complet. vocab.json et config.json ne sont pas des poids : # ce sont les deux referentiels sans lesquels le modele lit un autre texte que # celui dicte (voir l'en-tete du script, etape 3). $FichiersPack = @( 'encoder_model.onnx', 'decoder_model.onnx', 'source.spm', 'target.spm', 'vocab.json', 'config.json' ) # Modeles bilingues Helsinki-NLP, avec le francais comme pivot. Le nom du modele # ne se deduit pas toujours du code de la langue (no/nb, sr/hbs) : les alias de # la table sont essayes dans l'ordre, et c'est le code RETENU qui nomme le modele. # # DEUX FAMILLES sont essayees, dans cet ordre : # - « opus-mt » : le modele bilingue dedie, petit (~300 Mo par sens) ; # - « opus-mt-tc-big » : la version « big » (Tatoeba Challenge), publiee pour # des paires que la famille standard ne couvre pas — c'est elle qui peut # debloquer le portugais, le galicien, l'albanais, le georgien, l'hebreu… # Elle est NETTEMENT plus lourde (~1 Go par sens) : on ne la prend qu'a # defaut de la premiere. # Aucune n'est choisie a l'aveugle : chaque nom est cherche dans le catalogue # public AVANT tout telechargement, et le nom retenu est inscrit au rapport. $Directions = @( @{ Sens = 'versFrancais'; Gabarits = @('Helsinki-NLP/opus-mt-{0}-fr', 'Helsinki-NLP/opus-mt-tc-big-{0}-fr') } # -> francais @{ Sens = 'depuisFrancais'; Gabarits = @('Helsinki-NLP/opus-mt-fr-{0}', 'Helsinki-NLP/opus-mt-tc-big-fr-{0}') } # francais -> ) # Codes a essayer pour une langue : le code principal, puis ses alias, sans les # valeurs vides (une entree sans alias ne doit pas produire « opus-mt--fr »). function Get-Candidats { param($Langue) return @(@($Langue.Code) + @($Langue.Alias) | Where-Object { $_ }) } # Modele familial de cette langue, ou $null si elle n'en a pas. Une langue peut # ne pas figurer ici : c'est le cas de toutes celles qui ont deux paires # bilingues, et de celles qu'aucun modele familial ne couvre. function Get-ModeleFamilial { param([string]$Code) foreach ($entree in $ModelesFamiliaux) { if ($entree.Code -eq $Code) { return $entree } } return $null } # ============================================================ # Affichage # ============================================================ function Write-Etape { param([string]$Message) Write-Host ''; Write-Host $Message -ForegroundColor Cyan } function Write-Info { param([string]$Message) Write-Host (' ' + $Message) } function Write-Ok { param([string]$Message) Write-Host (' [OK] ' + $Message) -ForegroundColor Green } function Write-Alerte{ param([string]$Message) Write-Host (' [ALERTE] ' + $Message) -ForegroundColor Yellow } function Write-Ecart { param([string]$Message) Write-Host (' [ABSENT] ' + $Message) -ForegroundColor DarkYellow } function Stop-AvecMessage { param([string]$Message) Write-Host '' Write-Host ("ERREUR : " + $Message) -ForegroundColor Red exit 1 } function Format-Taille { param([long]$Octets) if ($Octets -ge 1073741824) { return ('{0:N2} Gio' -f ($Octets / 1073741824)) } if ($Octets -ge 1048576) { return ('{0:N1} Mio' -f ($Octets / 1048576)) } if ($Octets -ge 1024) { return ('{0:N0} Kio' -f ($Octets / 1024)) } return ("$Octets octets") } # ============================================================ # Nettoyage du cache de telechargement # ============================================================ # huggingface_hub range chaque depot telecharge sous \hub\models----. # Une fois l'export ONNX reussi, cette copie PyTorch (~300 Mo par sens) n'a plus # d'usage : la garder doublerait l'espace disque necessaire a l'operation. # Sans ce nettoyage, 50 langues occupent ~29 Gio de packs ET ~29 Gio de cache. function Remove-CacheModele { param([string]$Modele, [string]$Cache) $nom = 'models--' + ($Modele -replace '/', '--') if ($nom -notlike 'models--*') { return [long]0 } $racineCache = [System.IO.Path]::GetFullPath((Join-Path $Cache 'hub')) $dossier = Join-Path $racineCache $nom # Garde-fou : on ne supprime QUE sous \hub, et seulement un dossier # dont le nom est celui du modele. Aucune autre suppression n'est possible. $complet = [System.IO.Path]::GetFullPath($dossier) if (-not $complet.StartsWith($racineCache, [System.StringComparison]::OrdinalIgnoreCase)) { return [long]0 } if (-not (Test-Path -LiteralPath $complet -PathType Container)) { return [long]0 } $taille = [long]0 $mesure = (Get-ChildItem -LiteralPath $complet -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum if ($mesure) { $taille = [long]$mesure } try { Remove-Item -LiteralPath $complet -Recurse -Force -ErrorAction Stop return $taille } catch { # Un cache non supprime n'est jamais un echec : c'est de l'espace disque. Write-Alerte ("cache non supprime ({0}) : {1}" -f $complet, $_.Exception.Message) return [long]0 } } # ============================================================ # Disponibilite d'un modele sur HuggingFace # ============================================================ # Trois etats, et la distinction est essentielle : « la paire n'existe pas » # signifie qu'on ne pourra jamais la livrer, une erreur reseau signifie qu'on ne # SAIT PAS. Confondre les deux ferait conclure « aucune paire disponible » alors # que le poste est simplement hors ligne — et on chercherait le probleme du # mauvais cote. # # MESURE (export des 50 langues, 2026-10-16) : HuggingFace ne repond JAMAIS 404 # pour un depot absent — il repond 401 avec {"error":"Repository not found"}. # Les 55 paires manquantes l'ont toutes ete en 401, aucune en 404. Un « 401 » # seul est donc ambigu (depot absent ? prive ? jeton ?) : c'est le CORPS de la # reponse qui tranche. Le lire est la seule facon de ne pas ecrire un rapport # faux. # # Fonction PURE (code + corps + message) : elle est donc verifiable sans reseau. function Get-VerdictEchecHuggingFace { param( [int]$Code, [string]$Corps, [string]$MessageErreur ) if ($Code -eq 404) { return @{ Existe = $false; Injoignable = $false; Detail = 'paire inexistante (HTTP 404)' } } if ($Code -eq 401 -or $Code -eq 403) { $texte = if ($Corps) { $Corps.ToLowerInvariant() } else { '' } # Reponse REELLE de HuggingFace pour un depot absent. if ($texte -match 'not found') { return @{ Existe = $false; Injoignable = $false Detail = ("paire inexistante (HTTP $Code : depot introuvable)") } } # Tout autre 401 : le corps n'est pas lisible. Mesure du 16/10 sur ce # poste, au meme instant : un depot EXISTANT repond 200 (opus-mt-es-fr), # un depot absent repond 401 (API et page publique). Un 401 est donc bien # « ce depot n'est pas publie ». On le declare absent, en le disant : la # preuve textuelle manque, le code HTTP et le temoin 200 la remplacent. $extrait = if ($Corps) { ($Corps -replace '\s+', ' ').Trim() } else { 'aucun message' } if ($extrait.Length -gt 40) { $extrait = $extrait.Substring(0, 40) + '...' } return @{ Existe = $false; Injoignable = $false Detail = ("paire non publiee (HTTP $Code, corps illisible : $extrait ; un depot existant repond 200)") } } # Quota atteint : ce n'est PAS une absence, et relancer suffit. if ($Code -eq 429) { return @{ Existe = $false; Injoignable = $true; Detail = 'quota HuggingFace atteint (HTTP 429) : relancer plus tard' } } if ($Code -ge 500) { return @{ Existe = $false; Injoignable = $true; Detail = ("erreur du serveur HuggingFace (HTTP $Code)") } } return @{ Existe = $false; Injoignable = $true; Detail = $MessageErreur } } function Test-ModeleHuggingFaceUneFois { param([string]$Modele) $url = 'https://huggingface.co/api/models/' + $Modele try { $null = Invoke-WebRequest -Uri $url -Method Get -TimeoutSec 25 -UseBasicParsing -ErrorAction Stop return @{ Existe = $true; Injoignable = $false; Detail = 'disponible' } } catch { $code = 0 $corps = '' $reponse = $_.Exception.Response if ($reponse) { try { $code = [int]$reponse.StatusCode } catch { $code = 0 } } # Le corps porte la vraie raison. Constaté en vrai : le flux de la # réponse ne rend RIEN (« aucun message ») — PowerShell 5.1 l'a déjà # consommé. Le corps est en fait dans ErrorDetails.Message ; on essaie # donc celui-ci en premier, puis le flux (PowerShell 7). if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $corps = [string]$_.ErrorDetails.Message } if (-not $corps -and $reponse) { try { if ($reponse.PSObject.Methods['GetResponseStream']) { $lecteur = New-Object System.IO.StreamReader($reponse.GetResponseStream()) try { $corps = $lecteur.ReadToEnd() } finally { $lecteur.Dispose() } } elseif ($reponse.Content) { $corps = $reponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() } } catch { $corps = '' } } $verdict = Get-VerdictEchecHuggingFace -Code $code -Corps $corps -MessageErreur $_.Exception.Message return $verdict } } # ============================================================ # Le catalogue : la seule preuve d'existence qui ne se discute pas # ============================================================ # Historique des mesures sur ce poste, dans l'ordre : # 1. « Un depot absent repond 401, jamais 404 » — vrai, mais le CORPS de la # reponse ({"error":"Repository not found"}) n'etait pas toujours lisible ; # 2. on a donc interroge la PAGE publique, qui repondait 404 pour un depot # absent ; # 3. puis la page s'est mise a repondre 401 ELLE AUSSI, alors que le meme # navigateur obtenait 200 sur un depot existant au meme instant. Un 401 ne # discrimine donc plus rien, et un lancement a ecrit 55 fausses absences. # # La bonne reponse n'est pas un meilleur decodage d'erreur : c'est de ne plus # demander « ce depot existe-t-il ? » depot par depot. On lit UNE FOIS la liste # publique des depots de l'auteur (l'endpoint de recherche repond toujours 200, # avec un tableau JSON, meme vide), et l'existence devient une egalite de chaine. # # Benefices mesures : # - 1 a 3 requetes au lieu de 102 : plus de quota declenche, plus de 429 ; # - « absent » devient une PREUVE (le nom n'est pas dans la liste) et non plus # une deduction sur un code d'erreur ; # - le rapport peut citer la taille du catalogue consulte. function Get-CatalogueHelsinkiNLP { param( [string]$Auteur = 'Helsinki-NLP', [int]$TaillePage = 1000, [int]$PagesMax = 10 ) try { # InvariantCulture : « limit=1000 » ne doit pas dependre de la locale. $separateur = [System.Globalization.CultureInfo]::InvariantCulture $url = 'https://huggingface.co/api/models?author=' + $Auteur + '&limit=' + $TaillePage.ToString($separateur) $ids = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $pages = 0 while ($url -and $pages -lt $PagesMax) { $reponse = Invoke-WebRequest -Uri $url -Method Get -TimeoutSec 60 -UseBasicParsing -ErrorAction Stop $pages++ $lot = @($reponse.Content | ConvertFrom-Json) Write-Info ("catalogue : page {0} -> {1} depot(s)" -f $pages, $lot.Count) foreach ($modele in $lot) { if ($modele.id) { $null = $ids.Add([string]$modele.id) } } # Pagination : HuggingFace annonce la page suivante dans l'entete Link. $url = $null foreach ($entete in @($reponse.Headers['Link'])) { if ($entete -and ($entete -match '<([^>]+)>\s*;\s*rel="?next"?')) { $url = $Matches[1] } } } # GARDE-FOU MESURE : un catalogue de quelques entrees n'est PAS la liste # des depots, c'est une reponse tronquee (limite ignoree, page unique, # parametre non pris en compte). Le 16/10, une reponse a 2 entrees a fait # declarer « absents » 66 modeles dont 11 sont sur le disque depuis des # semaines : le script s'est contredit lui-meme (le pack italien existait # en 523 Mo et l'inventaire annoncait l'italien absent). Helsinki-NLP # publie des milliers de depots : en dessous de 100, on ne conclut RIEN # et l'appelant retombe sur l'interrogation par modele. if ($ids.Count -lt 100) { Write-Alerte ("catalogue inutilisable : {0} depot(s) seulement — reponse tronquee, verdict par modele" -f $ids.Count) return $null } return $ids } catch { # Catalogue illisible : on ne devine rien, l'appelant retombe sur # l'interrogation par modele et pourra dire « je ne sais pas ». return $null } } # Une sonde unique ne doit pas condamner 33 langues : un 429 (quota) ou une # coupure passagere est courant chez HuggingFace. Trois tentatives, avec une # attente croissante, avant de conclure « on ne sait pas ». # # Avec -Catalogue (une table des depots publics), la reponse est immediate et # sans reseau : c'est le chemin normal. Sans catalogue, on retombe sur la sonde # directe, seule source d'information disponible. function Test-ModeleHuggingFace { param([string]$Modele, $Catalogue = $null, [int]$Tentatives = 3) if ($Catalogue) { if ($Catalogue.Contains($Modele)) { return @{ Existe = $true; Injoignable = $false; Detail = 'disponible (catalogue Helsinki-NLP)' } } return @{ Existe = $false; Injoignable = $false Detail = ("absent du catalogue public ({0} depots {1} consultes)" -f $Catalogue.Count, 'Helsinki-NLP') } } $verdict = $null for ($essai = 1; $essai -le $Tentatives; $essai++) { $verdict = Test-ModeleHuggingFaceUneFois -Modele $Modele if (-not $verdict.Injoignable) { return $verdict } if ($essai -lt $Tentatives) { $attente = 3 * $essai Write-Info ("HuggingFace ne repond pas pour {0} ({1}) : nouvel essai dans {2} s" -f $Modele, $verdict.Detail, $attente) Start-Sleep -Seconds $attente } } return $verdict } # ============================================================ # Export d'un modele (bilingue ou familial) # ============================================================ # Un sens est « complet » quand il porte au moins un .onnx et un .spm : c'est # exactement ce que l'application exige pour traduire. Sert a ne PAS repayer un # telechargement de 500 Mo a 1 Go lorsqu'on complete une langue a moitie faite # avec -InclureDirectionsPartielles. function Test-SensComplet { param([string]$Dossier) if (-not (Test-Path -LiteralPath $Dossier)) { return $false } $onnx = @(Get-ChildItem -LiteralPath $Dossier -Filter '*.onnx' -File -ErrorAction SilentlyContinue) $spm = @(Get-ChildItem -LiteralPath $Dossier -Filter '*.spm' -File -ErrorAction SilentlyContinue) return ($onnx.Count -gt 0 -and $spm.Count -gt 0) } # Un pack FAMILIAL est complet quand commun/ porte les poids et chaque sens son # modele.json — c'est la seule chose que le dossier d'un sens doit contenir. # Sans ce controle, chaque relance reexporterait 1,15 Go pour rien. function Test-FamilleComplete { param([string]$DossierLangue) $dossierCommun = Join-Path $DossierLangue $DossierCommun if (-not (Test-Path -LiteralPath $dossierCommun)) { return $false } $onnx = @(Get-ChildItem -LiteralPath $dossierCommun -Filter '*.onnx' -File -ErrorAction SilentlyContinue) $spm = @(Get-ChildItem -LiteralPath $dossierCommun -Filter '*.spm' -File -ErrorAction SilentlyContinue) if ($onnx.Count -eq 0 -or $spm.Count -eq 0) { return $false } foreach ($sens in $SensCanoniques) { if (-not (Test-Path -LiteralPath (Join-Path (Join-Path $DossierLangue $sens) $FichierMetadonnees))) { return $false } } return $true } # Lance optimum-cli et laisse dans $DossierTravail de quoi etre copie. Le code de # sortie est le SEUL juge d'un export (voir le commentaire sur stderr ci-dessous). function Invoke-ExportOptimum { param( [string]$Modele, [string]$DossierTravail ) if (Test-Path -LiteralPath $DossierTravail) { Remove-Item -LiteralPath $DossierTravail -Recurse -Force } $null = New-Item -ItemType Directory -Path $DossierTravail -Force Write-Info ("export : " + $Modele) # optimum-cli ecrit sur stderr des avertissements benignes — le premier etant # celui de huggingface_hub qui signale que le cache travaille sans liens # symboliques. Avec $ErrorActionPreference = 'Stop' ET des que l'appelant # redirige le flux d'erreur (2>&1, Tee-Object, ou « > fichier.log » pose sur # tout le script), PowerShell 5.1 transforme CHACUNE de ces lignes en erreur # FATALE : l'export est tue juste apres le config.json, avant le moindre # octet de poids, et le rapport annonce un echec qui n'en est pas un. # Mesure en vrai : 11 exports tues ainsi, 0 octet telecharge, 23 Gio deja # sur le disque presentes comme « en echec ». # # Seul le CODE DE SORTIE juge un export. On abaisse donc la severite le temps # de l'appel, et on garde les dernieres lignes pour le diagnostic. $severite = $ErrorActionPreference $ErrorActionPreference = 'Continue' $dernieres = New-Object 'System.Collections.Generic.List[string]' $codeSortie = -1 try { & $OptimumCli export onnx --model $Modele $DossierTravail 2>&1 | ForEach-Object { $ligne = ([string]$_).TrimEnd() if ($ligne -match '\S') { Write-Host (' ' + $ligne) $dernieres.Add($ligne) if ($dernieres.Count -gt 3) { $dernieres.RemoveAt(0) } } } $codeSortie = $LASTEXITCODE } finally { $ErrorActionPreference = $severite } if ($codeSortie -ne 0) { $detail = if ($dernieres.Count -gt 0) { ($dernieres -join ' | ') } else { 'aucun message' } if ($detail.Length -gt 300) { $detail = $detail.Substring($detail.Length - 300) } return @{ Ok = $false; Detail = ("optimum-cli a echoue (code $codeSortie) : " + $detail) } } return @{ Ok = $true } } # Copie les fichiers UTILES d'un dossier d'export vers un dossier du pack : # encoder, decodeur, .spm, vocab.json, config.json. Retourne le nombre de # fichiers copies (0 = export inexploitable). # # decoder_with_past_model.onnx n'est JAMAIS copie : inutilise par # l'application, mais telecharge par chaque telephone s'il est declare. function Copier-FichiersPack { param( [string]$DossierTravail, [string]$DossierCible ) $principal = Join-Path $DossierTravail 'encoder_model.onnx' if (-not (Test-Path -LiteralPath $principal)) { # Autre convention de nommage : on prend le premier .onnx qui n'est ni un # decodeur ni un decodeur a cache. $candidat = @(Get-ChildItem -LiteralPath $DossierTravail -Filter '*.onnx' -File | Where-Object { $_.Name -notmatch 'decoder' -and $_.Name -notmatch 'past' } | Sort-Object Name | Select-Object -First 1) if ($candidat.Count -eq 0) { return 0 } $principal = $candidat[0].FullName } $null = New-Item -ItemType Directory -Path $DossierCible -Force $copies = 0 Copy-Item -LiteralPath $principal -Destination (Join-Path $DossierCible 'encoder_model.onnx') -Force $copies++ # Decodeur separe : facultatif (sinon l'application utilise le mode « modele # unique »), mais c'est la forme attendue d'un export Marian. $decodeur = @(Get-ChildItem -LiteralPath $DossierTravail -Filter '*.onnx' -File | Where-Object { $_.Name -match 'decoder' -and $_.Name -notmatch 'past' } | Sort-Object Name | Select-Object -First 1) if ($decodeur.Count -gt 0) { Copy-Item -LiteralPath $decodeur[0].FullName -Destination (Join-Path $DossierCible 'decoder_model.onnx') -Force $copies++ } else { Write-Alerte ("pas de decodeur separe dans l'export : mode « modele unique »") } # Vocabulaires : source.spm (entree) et target.spm (sortie). Les deux sont # obligatoires pour un modele Marian — un seul produirait du charabia. $vocabulaires = @(Get-ChildItem -LiteralPath $DossierTravail -Filter '*.spm' -File | Sort-Object Name) foreach ($vocabulaire in $vocabulaires) { Copy-Item -LiteralPath $vocabulaire.FullName -Destination (Join-Path $DossierCible $vocabulaire.Name) -Force $copies++ } if ($vocabulaires.Count -eq 0) { Write-Alerte 'aucun .spm dans l''export : le tokeniseur ne pourra pas fonctionner' } # vocab.json : le vocabulaire REELLEMENT attendu par le modele. Le .spm ne # numerote pas ses pieces comme lui (mesure : ▁Ho = 5293 cote .spm, 584 cote # modele), et il ne contient pas les etiquettes de langue des modeles # multilingues (>>fra<< = 14666). Sans ce fichier, le modele lit un autre # texte et produit du charabia sans lever d'erreur. # # config.json : l'amorce et la fin du decodeur (74821 et 0 pour opus-mt-es-fr), # que le .spm ne contient pas non plus. Fichiers de quelques centaines de # kilo-octets : leur poids est negligeable devant les ~700 Mo de poids. foreach ($nomJson in @('vocab.json', 'config.json')) { $cheminJson = Join-Path $DossierTravail $nomJson if (Test-Path -LiteralPath $cheminJson -PathType Leaf) { Copy-Item -LiteralPath $cheminJson -Destination (Join-Path $DossierCible $nomJson) -Force $copies++ } else { Write-Alerte ("{0} absent de l'export : le moteur lira les identifiants du .spm (traduction fausse possible)" -f $nomJson) } } return $copies } # Un sens bilingue : tout le pack est ecrit dans le dossier du sens. function Export-Sens { param( [string]$Modele, [string]$DossierSens, [string]$DossierTravail ) $resultat = Invoke-ExportOptimum -Modele $Modele -DossierTravail $DossierTravail if (-not $resultat.Ok) { return $resultat } $copies = Copier-FichiersPack -DossierTravail $DossierTravail -DossierCible $DossierSens if ($copies -le 0) { return @{ Ok = $false; Detail = 'aucun modele principal produit par l''export' } } $taille = (Get-ChildItem -LiteralPath $DossierSens -File | Measure-Object Length -Sum).Sum return @{ Ok = $true; Detail = ("$copies fichier(s), " + (Format-Taille $taille)); Taille = $taille } } # Ecrit le `modele.json` d'un sens. Le JSON est ecrit a la main plutot que par # ConvertTo-Json : ce dernier echappe « > » en \u003e, alors que le jeton de cible # est justement ce qu'un humain doit pouvoir lire en ouvrant le fichier. function Ecrire-MetadonneesSens { param( [string]$DossierSens, [string]$Modele, [string]$Famille, [string]$Jeton ) $null = New-Item -ItemType Directory -Path $DossierSens -Force $lignes = @( '{' ' "version": 1,' (' "modeleSource": "{0}",' -f $Modele) (' "famille": "{0}",' -f $Famille) (' "jetonCible": "{0}"' -f $Jeton) '}' ) $encodage = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText( (Join-Path $DossierSens $FichierMetadonnees), (($lignes -join "`r`n") + "`r`n"), $encodage) } # Un modele FAMILIAL : les poids vont UNE seule fois dans \commun\, et # chaque sens ne recoit que son modele.json (le jeton de cible). L'application # cherche d'abord dans le dossier du sens, puis dans commun. function Export-Famille { param( [string]$Modele, [string]$DossierLangue, [string]$DossierTravail, [string]$Famille, [string]$JetonDepuisFrancais ) $resultat = Invoke-ExportOptimum -Modele $Modele -DossierTravail $DossierTravail if (-not $resultat.Ok) { return $resultat } $dossierCommun = Join-Path $DossierLangue $DossierCommun $copies = Copier-FichiersPack -DossierTravail $DossierTravail -DossierCible $dossierCommun if ($copies -le 0) { return @{ Ok = $false; Detail = 'aucun modele principal produit par l''export' } } # Chaque sens ne garde QUE son modele.json. Un pack bilingue anterieur ferait # doublon : ses poids l'emporteraient sur commun (priorite du dossier du sens) # et le jeton de langue du familial s'appliquerait alors a un modele qui ne le # veut pas — traduction fausse, sans erreur. On retire donc les fichiers de # modele du sens avant d'y ecrire les metadonnees. $jetons = @( @{ Sens = 'versFrancais'; Jeton = $JetonVersFrancais } @{ Sens = 'depuisFrancais'; Jeton = $JetonDepuisFrancais } ) foreach ($entree in $jetons) { $dossierSens = Join-Path $DossierLangue $entree.Sens $null = New-Item -ItemType Directory -Path $dossierSens -Force $anciens = @(Get-ChildItem -LiteralPath $dossierSens -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '\.(onnx|spm)$' -or $_.Name -eq 'vocab.json' -or $_.Name -eq 'config.json' }) foreach ($ancien in $anciens) { Remove-Item -LiteralPath $ancien.FullName -Force -ErrorAction SilentlyContinue } if ($anciens.Count -gt 0) { Write-Info ("{0} : {1} fichier(s) bilingue(s) remplace(s) par le modele familial" -f $entree.Sens, $anciens.Count) } Ecrire-MetadonneesSens -DossierSens $dossierSens -Modele $Modele -Famille $Famille -Jeton $entree.Jeton } $taille = (Get-ChildItem -LiteralPath $DossierLangue -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum return @{ Ok = $true Detail = ("{0} fichier(s) dans {1}, modele.json par sens, {2}" -f $copies, $DossierCommun, (Format-Taille $taille)) Taille = $taille } } # ============================================================ # Verification des prerequis (sauf en simulation et en inventaire) # ============================================================ # -SansExporter n'utilise ni Python ni optimum : il interroge HuggingFace en # HTTP. L'inventaire doit donc fonctionner AVANT d'installer 3 Go d'outils. # # Ou pip depose les scripts Python. Deux pieges reels, rencontres en vrai : # - Python du Microsoft Store : le dossier utilisateur n'est PAS dans le PATH, # donc « pip install optimum » reussit et « optimum-cli » reste introuvable ; # - environnement virtuel (venv), la solution quand l'installation echoue sur # la limite de 260 caracteres de Windows : les scripts sont dans le venv, et # « nt_user » pointe ailleurs. # On interroge donc les DEUX emplacements, dans l'ordre : le schema par defaut de # l'interpreteur (le venv s'il y en a un), puis le dossier utilisateur. function Get-DossiersScriptsPython { param([string]$Interpreteur) # Un interpreteur bavard sur stderr ne doit pas interrompre cette recherche. $ErrorActionPreference = 'Continue' $dossiers = @() try { $requete = "import sysconfig; print(sysconfig.get_path('scripts')); print(sysconfig.get_path('scripts','nt_user'))" $sortie = @(& $Interpreteur -c $requete 2>$null) if ($LASTEXITCODE -eq 0) { foreach ($ligne in $sortie) { $dossier = ([string]$ligne).Trim() if ($dossier) { $dossiers += $dossier } } } } catch { } # Emplacements des installations classiques (python.org, par utilisateur). if ($env:APPDATA) { $racineAppData = Join-Path $env:APPDATA 'Python' $dossiers += (Join-Path $racineAppData 'Scripts') $dossiers += @(Get-ChildItem -LiteralPath $racineAppData -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'Scripts' }) } if ($env:LOCALAPPDATA) { $dossiers += @(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'Programs\Python') -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'Scripts' }) } return @($dossiers | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) }) } function Find-OutilHorsPath { param([string]$Nom, [string]$Interpreteur) # L'appelant a deja donne un chemin : on ne devine rien a sa place. if ($Nom -match '[\\/]') { return $null } foreach ($dossier in @(Get-DossiersScriptsPython -Interpreteur $Interpreteur)) { foreach ($extension in @('.exe', '.cmd', '.bat', '')) { $candidat = Join-Path $dossier ($Nom + $extension) if (Test-Path -LiteralPath $candidat -PathType Leaf) { return $candidat } } } return $null } if (-not $Simulation -and -not $SansExporter) { # Python d'abord : c'est le prerequis du prerequis (l'outil d'export est un # script Python). L'annoncer en premier evite d'envoyer l'utilisateur # installer un paquet dans un Python qui ne fonctionne pas. try { $null = & $Python --version 2>&1 if ($LASTEXITCODE -ne 0) { throw 'python en echec' } } catch { Stop-AvecMessage ("Python introuvable via « $Python ». Verifier l'installation de Python 3.") } $optimum = Get-Command $OptimumCli -ErrorAction SilentlyContinue if (-not $optimum) { $horsPath = Find-OutilHorsPath -Nom $OptimumCli -Interpreteur $Python if ($horsPath) { Write-Info ("$OptimumCli trouve hors PATH : $horsPath") $OptimumCli = $horsPath $optimum = Get-Command $OptimumCli -ErrorAction SilentlyContinue } } if (-not $optimum) { $message = ("« $OptimumCli » est introuvable. Installer d'abord (une seule fois) :`n" + ' pip install "optimum[onnxruntime]" transformers sentencepiece') $dossierScripts = @(Get-DossiersScriptsPython -Interpreteur $Python) if ($dossierScripts.Count -gt 0) { $message += ("`nSi l'installation a reussi mais que l'outil reste introuvable, c'est`n" + "que ce dossier n'est pas dans le PATH — le script sait y aller tout seul,`n" + "mais verifiez qu'il contient bien « $OptimumCli » :`n " + $dossierScripts[0]) } Stop-AvecMessage $message } } # ============================================================ # Selection des langues # ============================================================ $selection = @($Langues) if ($Codes) { # Le decoupage sur « , » et « ; » n'est pas cosmetique : -Codes es,pt est un # TABLEAU pour PowerShell, mais -Codes "es,pt" (entre guillemets) ou # -Codes $liste (variable) est UNE SEULE chaine. Sans ce decoupage, la chaine # entiere etait vue comme un unique code inconnu, et le script s'arretait sur # « Aucune langue selectionnee » — piege rencontre en vrai. $voulus = @($Codes | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) $selection = @($selection | Where-Object { $voulus -contains $_.Code }) $inconnus = @($voulus | Where-Object { $c = $_; -not ($Langues | Where-Object { $_.Code -eq $c }) }) foreach ($inconnu in $inconnus) { Write-Alerte ("code inconnu, ignore : $inconnu") } } if ($Zone) { $zones = @($Zone | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $selection = @($selection | Where-Object { $zones -contains $_.Zone }) } if ($selection.Count -eq 0) { Stop-AvecMessage 'Aucune langue selectionnee. Verifier -Codes / -Zone.' } # -SansExporter n'a de sens qu'avec la verification reelle : combine a # -SansVerifierHuggingFace, le script declarerait TOUTES les paires disponibles # sans avoir rien regarde, et le rapport affirmerait le contraire de la verite. if ($SansExporter -and $SansVerifierHuggingFace) { Stop-AvecMessage ('-SansExporter a besoin de la verification HuggingFace :' + "`n" + ' retirez -SansVerifierHuggingFace (pour exporter sans verifier, lancez' + "`n" + ' simplement le script sans -SansExporter).') } $racine = if ([System.IO.Path]::IsPathRooted($RacinePacks)) { $RacinePacks } else { Join-Path $RepoRoot $RacinePacks } $racine = $racine.TrimEnd([char[]]@('\', '/')) # --- Cache de telechargement HuggingFace ------------------------------------- # optimum-cli telecharge le modele PyTorch via huggingface_hub, qui le range dans # HF_HOME (defaut : %USERPROFILE%\.cache\huggingface). Sans intervention, chaque # modele occupe donc DEUX fois sa taille : une fois dans le cache (sur C:) et une # fois sous forme d'export ONNX. On force donc le cache a cote des packs, sauf si # l'administrateur a deja defini HF_HOME. if (-not $CacheHuggingFace) { if ($env:HF_HOME) { $CacheHuggingFace = $env:HF_HOME $cacheImpose = $false } else { $CacheHuggingFace = Join-Path $racine '.hf-cache' $cacheImpose = $true } } else { $CacheHuggingFace = $CacheHuggingFace.TrimEnd([char[]]@('\', '/')) $cacheImpose = $true } if ($cacheImpose) { $env:HF_HOME = $CacheHuggingFace } Write-Host '' Write-Host '=== Omika — Export des packs de langue (ONNX) ===' -ForegroundColor White Write-Info ("Langues selectionnees : {0}" -f $selection.Count) Write-Info ("Packs ecrits dans : {0}" -f $racine) Write-Info ("Cache HuggingFace : {0}{1}" -f $CacheHuggingFace, $(if ($cacheImpose) { ' (force par le script)' } else { ' (HF_HOME existant : respecte)' })) if ($Simulation) { Write-Info 'Mode : SIMULATION (aucun reseau, aucun export, aucune ecriture)' } # ============================================================ # SIMULATION : le plan, avant de payer 30 Go # ============================================================ if ($Simulation) { Write-Host '' Write-Host '--- Plan (2 modeles bilingues par langue : ->fr et fr->) ---' $totalExports = 0 foreach ($langue in $selection) { $candidats = Get-Candidats -Langue $langue $noms = ($candidats | ForEach-Object { "opus-mt-$_-fr / opus-mt-fr-$_" }) -join ' sinon ' Write-Host (" {0,-4} {1,-16} {2,-14} {3}" -f $langue.Code, $langue.Libelle, $langue.Zone, $noms) $totalExports += 2 } $estimation = [long]$totalExports * $EstimationMoParSens * 1MB # Repli familial : il ne coute RIEN de plus au plan tant qu'une paire bilingue # existe — il n'est employe que si une direction manque (voir le rapport). $famillesSelectionnees = @($selection | Where-Object { Get-ModeleFamilial -Code $_.Code }) if ($famillesSelectionnees.Count -gt 0) { Write-Host '' Write-Info ("Repli familial pour {0} langue(s) : un SEUL export couvre les deux sens," -f $famillesSelectionnees.Count) Write-Info ('poids ecrits une seule fois dans \commun\ et partages par l''aller et le retour :') foreach ($langueFamille in $famillesSelectionnees) { $famille = Get-ModeleFamilial -Code $langueFamille.Code Write-Info (" {0,-4} {1} (aller {2}, retour {3})" -f $langueFamille.Code, $famille.Modele, $JetonVersFrancais, $famille.JetonDepuis) } } Write-Host '' Write-Info 'Chaque sens est cherche en « opus-mt », PUIS en « opus-mt-tc-big » (bien plus' Write-Info 'lourd : ~1 Go par sens). Aucun nom n''est retenu sans avoir ete verifie, et le' Write-Info 'modele reellement employe est inscrit au rapport.' Write-Host '' Write-Info ("Modeles a exporter : {0} (2 par langue)" -f $totalExports) Write-Info ("Telechargement estime : ~{0} (estimation haute, hors fichiers temporaires d'export)" -f (Format-Taille $estimation)) Write-Info 'Duree : de l''ordre de plusieurs heures (chaque paire est telechargee puis convertie).' Write-Host '' Write-Info 'Ou vont les fichiers :' Write-Info (" packs ONNX {0}\\\ (~{1} au total)" -f $racine, (Format-Taille $estimation)) Write-Info (" cache HuggingFace {0}\hub\ (supprime apres chaque export reussi)" -f $CacheHuggingFace) Write-Info (" travail temporaire {0}\.tmp--\ (supprime apres chaque sens)" -f $racine) Write-Info 'Pic de disque reel : les packs + ~300 Mo (un seul modele en cache a la fois).' Write-Host '' Write-Info 'Les paires reellement disponibles ne sont connues qu''a l''execution : le script' Write-Info 'lit le catalogue public Helsinki-NLP (une seule requete), puis ecrit au rapport' Write-Info 'les paires ABSENTES (Helsinki-NLP ne couvre pas toutes les combinaisons avec le' Write-Info 'francais). Prevoir que plusieurs langues de la liste seront indisponibles.' Write-Host '' Write-Info 'Pour lancer reellement :' Write-Info ' .\scripts\Exporter-Packs-Langues.ps1' exit 0 } # ============================================================ # Export reel # ============================================================ $rapportConstruits = New-Object 'System.Collections.Generic.List[object]' $rapportAbsents = New-Object 'System.Collections.Generic.List[object]' $rapportPartiels = New-Object 'System.Collections.Generic.List[object]' $rapportEchecs = New-Object 'System.Collections.Generic.List[object]' $totalOctets = [long]0 $cacheLibere = [long]0 $reseauEnEchec = $false $motifInjoignable = '' # Le catalogue est lu UNE seule fois pour tout le run : 1 a 3 requetes au lieu # d'une par modele (102 pour la table complete). C'est ce qui met fin aux 429. $catalogue = $null if (-not $SansVerifierHuggingFace) { Write-Info 'Lecture du catalogue public Helsinki-NLP (une seule fois pour tout le run)...' $catalogue = Get-CatalogueHelsinkiNLP if ($catalogue) { Write-Ok ("catalogue : {0} depots publics" -f $catalogue.Count) } else { Write-Alerte 'catalogue illisible : repli sur une interrogation par modele (quota possible)' } } foreach ($langue in $selection) { $code = $langue.Code $dossierLangue = Join-Path $racine $code Write-Etape ("--- {0} ({1}) ---" -f $langue.Libelle, $code) # Deja construit : deux dossiers de sens non vides — ou un pack familial # complet (commun/ + un modele.json par sens). if (-not $Forcer) { if (Test-FamilleComplete -DossierLangue $dossierLangue) { $taille = (Get-ChildItem -LiteralPath $dossierLangue -Recurse -File | Measure-Object Length -Sum).Sum $totalOctets += $taille Write-Ok ('deja present (modele familial), ignore (' + (Format-Taille $taille) + ') — -Forcer pour reexporter') $rapportConstruits.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone VersFrancais = 'deja present'; DepuisFrancais = 'deja present' Taille = $taille; Statut = 'deja present (familial)' }) continue } $dejaLa = $true foreach ($sens in $SensCanoniques) { $dossierSens = Join-Path $dossierLangue $sens if (-not (Test-Path -LiteralPath $dossierSens)) { $dejaLa = $false; break } $onnx = @(Get-ChildItem -LiteralPath $dossierSens -Filter '*.onnx' -File -ErrorAction SilentlyContinue) if ($onnx.Count -eq 0) { $dejaLa = $false; break } } if ($dejaLa) { $taille = (Get-ChildItem -LiteralPath $dossierLangue -Recurse -File | Measure-Object Length -Sum).Sum $totalOctets += $taille Write-Ok ('deja present, ignore (' + (Format-Taille $taille) + ') — -Forcer pour reexporter') $rapportConstruits.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone VersFrancais = 'deja present'; DepuisFrancais = 'deja present' Taille = $taille; Statut = 'deja present' }) continue } } # --- Disponibilite des deux directions ----------------------------------- $retenues = @() $manquantes = @() $couvertParFamille = $false foreach ($direction in $Directions) { $trouve = $null $detail = '' $essayes = @() $arretReseau = $false if ($SansVerifierHuggingFace) { # @(...) OBLIGATOIRE : PowerShell « deroule » un tableau a un seul # element, donc (Get-Candidats ...)[0] renverrait le premier CARACTERE # du code ('es' -> 'e') et produirait « opus-mt-e-fr ». # # On ne choisit PAS un nom : on emporte TOUTES les familles dans # l'ordre (petite d'abord, « tc-big » ensuite). L'export les essaiera # l'une apres l'autre et gardera la premiere qui aboutit — sans # verification prealable, c'est l'essai lui-meme qui tranche. Sans # cela, -SansVerifierHuggingFace ne pouvait jamais atteindre un # modele « tc-big », seule chance du georgien ou de l'armenien. $codeRetenu = @(Get-Candidats -Langue $langue)[0] foreach ($gabarit in $direction.Gabarits) { $essayes += ($gabarit -f $codeRetenu) } $trouve = $essayes[0] } else { # Les DEUX familles, la petite d'abord : on ne sort de la boucle que # lorsqu'un nom a repondu 200. foreach ($gabarit in $direction.Gabarits) { foreach ($candidat in (Get-Candidats -Langue $langue)) { $modele = $gabarit -f $candidat $essayes += $modele $verdict = Test-ModeleHuggingFace -Modele $modele -Catalogue $catalogue if ($verdict.Injoignable) { $reseauEnEchec = $true $arretReseau = $true $detail = $verdict.Detail $motifInjoignable = ("{0} : {1}" -f $modele, $verdict.Detail) break } if ($verdict.Existe) { $trouve = $modele; break } $detail = $verdict.Detail } if ($trouve -or $arretReseau) { break } } } if ($trouve) { # Modeles : les noms a essayer a l'export, dans l'ordre. En mode # verifie il n'y en a qu'un (le catalogue a tranche) ; sans # verification, l'export les essaie tous jusqu'au premier succes. $modelesPossibles = if ($SansVerifierHuggingFace) { @($essayes) } else { @($trouve) } $retenues += @{ Sens = $direction.Sens; Modele = $trouve; Modeles = $modelesPossibles } } else { # On inscrit TOUS les noms essayes : le rapport doit montrer l'effort # reel, et pas seulement le premier nom tente. $manquantes += @{ Sens = $direction.Sens; Modele = ($essayes -join ' / '); Detail = $detail } } } # --- Modele familial : UN modele pour les DEUX sens ---------------------- # Employe seulement si une direction n'a pas de paire bilingue (pt et gl : # aucune ; it : fr->it absent). Les deux sens viennent alors du MEME modele : # un export, des poids partages dans commun/. En mode verifie on interroge le # catalogue ; sans verification, c'est l'export qui tranchera (voir le repli # dans la boucle d'export plus bas). # # Une direction bilingue eventuellement trouvee est ABANDONNEE (it : it-fr) : # melanger un bilingue et un familial dans un meme pack ferait appliquer le # jeton de langue du familial a des poids qui n'en veulent pas — traduction # fausse, sans erreur. Un seul modele par langue, donc. $famille = Get-ModeleFamilial -Code $code if ($famille -and $manquantes.Count -gt 0 -and -not $SansVerifierHuggingFace) { $verdictFamille = Test-ModeleHuggingFace -Modele $famille.Modele -Catalogue $catalogue if ($verdictFamille.Injoignable) { $reseauEnEchec = $true $motifInjoignable = ("{0} : {1}" -f $famille.Modele, $verdictFamille.Detail) } elseif ($verdictFamille.Existe) { Write-Info ("modele familial : {0} couvre les deux sens (aller {1}, retour {2})" -f $famille.Modele, $JetonVersFrancais, $famille.JetonDepuis) $retenues = @(@{ Sens = 'famille'; Modele = $famille.Modele; Modeles = @($famille.Modele); Famille = $famille }) # Les paires absentes restent au rapport (elles le sont) : elles y sont # simplement marquees comme couvertes par le modele familial. $couvertParFamille = $true } else { Write-Ecart ("modele familial absent : {0} ({1})" -f $famille.Modele, $verdictFamille.Detail) } } # Un verdict « je ne sais pas » interrompt TOUT le run, pas seulement la # langue courante. Raison mesuree : une version anterieure a continue apres # des reponses 401/429 et ecrit 55 « paires absentes » dont beaucoup sont # fausses — un rapport qui fait supprimer 22 langues du produit. Une absence # non prouvee ne s'ecrit pas, et continuer ne fait qu'empiler des verdicts # faux. Ce cas ne peut plus arriver quand le catalogue a pu etre lu : il ne # reste alors aucune interrogation reseau par modele. if ($reseauEnEchec) { $motif = if ($motifInjoignable) { $motifInjoignable } else { 'aucun detail' } Stop-AvecMessage ("HuggingFace ne repond pas ($motif).`n" + ' AUCUN modele n''a ete declare absent : on ne SAIT pas si ces paires existent.' + "`n Ni la liste publique des depots (1 requete) ni l''interrogation par modele" + "`n n'ont abouti : reseau, proxy, ou blocage temporaire. Attendre 10 a 15 minutes," + "`n puis relancer la MEME commande : les packs deja sur le disque sont ignores," + "`n rien n'est retelecharge." + "`n Pour tenter sans verification (les noms essayes seront ecrits au rapport) :" + "`n .\scripts\Exporter-Packs-Langues.ps1 -SansVerifierHuggingFace") } foreach ($manquante in $manquantes) { # Une paire absente mais couverte par le modele familial n'est PAS un # echec : le rapport le dit, pour qu'on ne supprime pas la langue en # croyant qu'elle est perdue. $note = if ($couvertParFamille) { ' — couverte par le modele familial ' + $famille.Modele } else { '' } Write-Ecart ("paire absente : {0} ({1}){2}" -f $manquante.Modele, $manquante.Detail, $note) $rapportAbsents.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone Direction = $manquante.Sens; Modele = $manquante.Modele Detail = ($manquante.Detail + $note) }) } if ($manquantes.Count -gt 0 -and $retenues.Count -gt 0 -and -not $couvertParFamille -and -not $InclureDirectionsPartielles) { Write-Alerte 'pack INCOMPLET : la langue est ignoree (utiliser -InclureDirectionsPartielles pour la construire quand meme)' $rapportPartiels.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone DirectionsPresentes = (($retenues | ForEach-Object { $_.Sens }) -join ', ') DirectionsAbsentes = (($manquantes | ForEach-Object { $_.Sens }) -join ', ') }) continue } if ($retenues.Count -eq 0) { Write-Ecart 'aucune direction disponible : langue ignoree' continue } # --- Inventaire seul : on s'arrete ici, rien n'est telecharge ------------- # Les deux directions existent : la langue est livrable. On l'inscrit au # rapport avec le statut « disponible (non exporte) », pour que le lecteur # distingue ce qui a ete verifie de ce qui est deja sur le disque. if ($SansExporter) { # Le modele familial couvre les DEUX sens : il est toujours seul retenu. $familial = @($retenues | Where-Object { $_.Sens -eq 'famille' }).Count -gt 0 $sensRetenus = if ($familial) { $SensCanoniques } else { @($retenues | ForEach-Object { $_.Sens }) } Write-Ok ("disponible : {0}{1}" -f (($retenues | ForEach-Object { $_.Modele }) -join ' + '), $(if ($familial) { ' (modele familial : deux sens)' } else { '' })) $rapportConstruits.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone VersFrancais = if ($sensRetenus -contains 'versFrancais') { 'disponible' } else { 'ABSENT' } DepuisFrancais = if ($sensRetenus -contains 'depuisFrancais') { 'disponible' } else { 'ABSENT' } Taille = [long]0; Statut = $(if ($familial) { 'disponible (familial, non exporte)' } else { 'disponible (non exporte)' }) }) continue } # --- Export -------------------------------------------------------------- $taillesSens = @{} $echec = $false $familleExportee = $false foreach ($retenue in $retenues) { # Le familial vient de couvrir les deux sens : les retenues restantes # (une par sens, trouvees avant le repli) n'ont plus rien a exporter. if ($familleExportee) { continue } # --- Modele familial : UN export pour les deux sens ------------------ if ($retenue.Sens -eq 'famille') { $courante = $retenue.Famille if (-not $Forcer -and (Test-FamilleComplete -DossierLangue $dossierLangue)) { $tailleDeja = (Get-ChildItem -LiteralPath $dossierLangue -Recurse -File | Measure-Object Length -Sum).Sum $taillesSens['versFrancais'] = $tailleDeja $taillesSens['depuisFrancais'] = 0 $familleExportee = $true Write-Ok ('modele familial : deja present, ignore ({0})' -f (Format-Taille $tailleDeja)) continue } $dossierTravail = Join-Path $racine ('.tmp-' + $code + '-famille') $resultat = $null try { $resultat = Export-Famille -Modele $courante.Modele -DossierLangue $dossierLangue ` -DossierTravail $dossierTravail -Famille $courante.Famille ` -JetonDepuisFrancais $courante.JetonDepuis } catch { $resultat = @{ Ok = $false; Detail = $_.Exception.Message } } finally { if (Test-Path -LiteralPath $dossierTravail) { Remove-Item -LiteralPath $dossierTravail -Recurse -Force -ErrorAction SilentlyContinue } } if ($resultat.Ok -and -not $GarderCacheHuggingFace) { $cacheLibere += Remove-CacheModele -Modele $courante.Modele -Cache $CacheHuggingFace } if (-not $resultat.Ok) { Write-Alerte ("echec de l'export du modele familial : {0}" -f $resultat.Detail) $rapportEchecs.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Direction = 'famille' Modele = $courante.Modele; Detail = $resultat.Detail }) $echec = $true break } $taillesSens['versFrancais'] = $resultat.Taille $taillesSens['depuisFrancais'] = 0 $familleExportee = $true Write-Ok ("modele familial : {0}" -f $resultat.Detail) continue } $dossierSens = Join-Path $dossierLangue $retenue.Sens $dossierTravail = Join-Path $racine ('.tmp-' + $code + '-' + $retenue.Sens) # Sens deja construit : ne pas repayer 500 Mo a 1 Go. Sans ce garde-fou, # -InclureDirectionsPartielles reexporte le sens qui existait deja (le # pack n'est pas « complet », donc le test de langue entiere ne suffit # pas) : mesure en vrai, 11 langues a moitie faites = 11 telechargements # inutiles. « -Forcer » reste le seul moyen de reexporter. if (-not $Forcer -and (Test-SensComplet -Dossier $dossierSens)) { $tailleDeja = (Get-ChildItem -LiteralPath $dossierSens -Recurse -File | Measure-Object Length -Sum).Sum $taillesSens[$retenue.Sens] = $tailleDeja Write-Ok ("{0} : deja present, ignore ({1})" -f $retenue.Sens, (Format-Taille $tailleDeja)) continue } # Plusieurs noms possibles : « opus-mt » puis « opus-mt-tc-big ». Le # premier qui aboutit gagne ; on ne declare l'echec du sens qu'apres les # avoir TOUS essayes. C'est ce qui permet a -SansVerifierHuggingFace de # trouver les langues que seule la famille « tc-big » couvre. $modeles = @($retenue.Modeles) if ($modeles.Count -eq 0) { $modeles = @($retenue.Modele) } $resultat = $null foreach ($nomModele in $modeles) { if ($modeles.Count -gt 1) { Write-Info ("modele {0}/{1} : {2}" -f ($modeles.IndexOf($nomModele) + 1), $modeles.Count, $nomModele) } try { $resultat = Export-Sens -Modele $nomModele -DossierSens $dossierSens -DossierTravail $dossierTravail } catch { $resultat = @{ Ok = $false; Detail = $_.Exception.Message } } finally { # L'export ecrit aussi decoder_with_past_model.onnx (~120 Mo) et des # fichiers de tokenizer : on ne les garde jamais. if (Test-Path -LiteralPath $dossierTravail) { Remove-Item -LiteralPath $dossierTravail -Recurse -Force -ErrorAction SilentlyContinue } } if ($resultat.Ok) { $retenue.Modele = $nomModele break } if ($modeles.IndexOf($nomModele) -lt ($modeles.Count - 1)) { Write-Alerte ("{0} indisponible ({1}) — modele suivant" -f $nomModele, $resultat.Detail) } } # Cache du modele : supprime apres un export REUSSI (le seul cas ou la # copie PyTorch n'a plus d'usage). En cas d'echec, on le garde : relancer # ne retelechargera pas. if ($resultat.Ok -and -not $GarderCacheHuggingFace) { $cacheLibere += Remove-CacheModele -Modele $retenue.Modele -Cache $CacheHuggingFace } # Aucune paire bilingue exploitable (ex. fr->it) : le modele familial # couvre alors les DEUX sens en un seul export, poids partages. if (-not $resultat.Ok -and $famille -and -not $familleExportee) { Write-Alerte ("aucune paire bilingue exploitable pour {0} — repli sur {1}" -f $retenue.Sens, $famille.Modele) $dossierTravailFamille = Join-Path $racine ('.tmp-' + $code + '-famille') $resultatFamille = $null try { $resultatFamille = Export-Famille -Modele $famille.Modele -DossierLangue $dossierLangue ` -DossierTravail $dossierTravailFamille -Famille $famille.Famille ` -JetonDepuisFrancais $famille.JetonDepuis } catch { $resultatFamille = @{ Ok = $false; Detail = $_.Exception.Message } } finally { if (Test-Path -LiteralPath $dossierTravailFamille) { Remove-Item -LiteralPath $dossierTravailFamille -Recurse -Force -ErrorAction SilentlyContinue } } if ($resultatFamille.Ok) { if (-not $GarderCacheHuggingFace) { $cacheLibere += Remove-CacheModele -Modele $famille.Modele -Cache $CacheHuggingFace } $taillesSens['versFrancais'] = $resultatFamille.Taille $taillesSens['depuisFrancais'] = 0 $familleExportee = $true Write-Ok ("modele familial : {0}" -f $resultatFamille.Detail) continue } Write-Alerte ("le modele familial n'a pas pu etre exporte non plus : {0}" -f $resultatFamille.Detail) } if (-not $resultat.Ok) { Write-Alerte ("echec de l'export ({0}) : {1}" -f $retenue.Sens, $resultat.Detail) $rapportEchecs.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Direction = $retenue.Sens Modele = $retenue.Modele; Detail = $resultat.Detail }) $echec = $true break } $taillesSens[$retenue.Sens] = $resultat.Taille Write-Ok ("{0} : {1}" -f $retenue.Sens, $resultat.Detail) } if ($echec) { continue } # Controle final : un pack bilingue porte au moins un .onnx et un .spm par # sens ; un pack familial porte ses poids dans commun/ et un modele.json par # sens (c'est tout ce que le dossier d'un sens doit contenir). $conforme = $true if ($familleExportee) { if (-not (Test-FamilleComplete -DossierLangue $dossierLangue)) { Write-Alerte 'modele familial : commun incomplet ou modele.json manquant' $conforme = $false } } else { foreach ($retenue in $retenues) { $dossierSens = Join-Path $dossierLangue $retenue.Sens $onnx = @(Get-ChildItem -LiteralPath $dossierSens -Filter '*.onnx' -File) $spm = @(Get-ChildItem -LiteralPath $dossierSens -Filter '*.spm' -File) if ($onnx.Count -eq 0 -or $spm.Count -eq 0) { Write-Alerte ("{0} : pack incomplet (.onnx={1}, .spm={2})" -f $retenue.Sens, $onnx.Count, $spm.Count) $conforme = $false } } } $tailleLangue = (Get-ChildItem -LiteralPath $dossierLangue -Recurse -File | Measure-Object Length -Sum).Sum $totalOctets += $tailleLangue $rapportConstruits.Add([pscustomobject]@{ Code = $code; Libelle = $langue.Libelle; Zone = $langue.Zone VersFrancais = if ($taillesSens.ContainsKey('versFrancais')) { 'exporte' } else { 'ABSENT' } DepuisFrancais = if ($taillesSens.ContainsKey('depuisFrancais')) { 'exporte' } else { 'ABSENT' } Taille = $tailleLangue Statut = if (-not $conforme) { 'incomplet' } elseif ($familleExportee) { 'ok (familial)' } elseif ($taillesSens.Count -lt 2) { 'un seul sens' } else { 'ok' } }) } # ============================================================ # Rapport # ============================================================ # Poids REELLEMENT present sous la racine. Le total de l'execution ne suffit pas : # « -Codes it » n'affichait que 520 Mio alors que le dossier en contenait 21 Gio, # ce qui laissait croire a une disparition de packs. $totalDossier = [long]0 $sommeDossier = Get-ChildItem -LiteralPath $racine -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\\.' } | Measure-Object Length -Sum if ($sommeDossier) { $totalDossier = [long]$sommeDossier.Sum } Write-Host '' Write-Host '=== RESULTAT ===' -ForegroundColor White $libelleConstruits = if ($SansExporter) { 'Langues disponibles (inventaire)' } else { 'Packs construits / deja presents' } Write-Info ("{0,-33}: {1}" -f $libelleConstruits, $rapportConstruits.Count) Write-Info ("Paires indisponibles : {0}" -f $rapportAbsents.Count) Write-Info ("Langues ignorees (pack partiel) : {0}" -f $rapportPartiels.Count) Write-Info ("Echecs d'export : {0}" -f $rapportEchecs.Count) Write-Info ("{0,-33}: {1}" -f 'Poids de cette execution', (Format-Taille $totalOctets)) Write-Info ("Poids total sous {0} : {1}" -f $racine, (Format-Taille $totalDossier)) if (-not $GarderCacheHuggingFace) { Write-Info ("Cache HuggingFace libere : {0}" -f (Format-Taille $cacheLibere)) } foreach ($construit in $rapportConstruits) { Write-Ok ("{0,-4} {1,-16} {2,12} ({3})" -f $construit.Code, $construit.Libelle, (Format-Taille $construit.Taille), $construit.Statut) } foreach ($absent in $rapportAbsents) { Write-Ecart ("{0,-4} {1,-16} {2}" -f $absent.Code, $absent.Libelle, $absent.Modele) } foreach ($echecExport in $rapportEchecs) { Write-Alerte ("{0,-4} {1,-16} {2} : {3}" -f $echecExport.Code, $echecExport.Libelle, $echecExport.Direction, $echecExport.Detail) } # --- Rapport ecrit sur disque ------------------------------------------------ $lignes = New-Object 'System.Collections.Generic.List[string]' $lignes.Add('# Rapport d''export des packs de langue') $lignes.Add('') $lignes.Add(("Généré le {0} dans {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm'), $racine)) $lignes.Add('') if ($SansExporter) { $lignes.Add('Mode : **inventaire** — disponibilité des paires vérifiée sur HuggingFace,') $lignes.Add('aucun export lancé (rien n''a été téléchargé). Les langues déjà présentes') $lignes.Add('sur le disque n''ont pas été revérifiées.') $lignes.Add('') } $lignes.Add(("Poids des packs de cette exécution : {0}" -f (Format-Taille $totalOctets))) $lignes.Add(("Poids total sous {0} : {1}" -f $racine, (Format-Taille $totalDossier))) $lignes.Add('') $lignes.Add($(if ($SansExporter) { '## Paires disponibles (vérifiées, non exportées)' } else { '## Packs construits' })) $lignes.Add('') $lignes.Add('| Code | Langue | Zone | versFrancais | depuisFrancais | Poids | Statut |') $lignes.Add('|---|---|---|---|---|---|---|') foreach ($construit in $rapportConstruits) { $lignes.Add(("| {0} | {1} | {2} | {3} | {4} | {5} | {6} |" -f $construit.Code, $construit.Libelle, $construit.Zone, $construit.VersFrancais, $construit.DepuisFrancais, (Format-Taille $construit.Taille), $construit.Statut)) } $lignes.Add('') $lignes.Add('Les packs marqués **« ok (familial) »** viennent d''un modèle multilingue') $lignes.Add('partagé (`opus-mt-tc-big-itc-itc` pour it, es, pt, ca, gl, ro). Ses poids sont') $lignes.Add('écrits une seule fois dans `commun/` ; chaque sens ne porte qu''un `modele.json`') $lignes.Add('avec son jeton de cible (`>>fra<<` pour l''aller, `>>por<<` pour le retour).') $lignes.Add('Le téléphone ne télécharge donc ces poids qu''une seule fois.') $lignes.Add('') $lignes.Add('## Paires indisponibles') $lignes.Add('') $lignes.Add('Aucun modèle bilingue Helsinki-NLP avec le français. Sans paire, aucune') $lignes.Add('livraison n''est possible pour cette langue dans l''architecture actuelle.') $lignes.Add('HuggingFace répond « 401 — dépôt introuvable » (et non 404) pour un dépôt') $lignes.Add('absent : c''est le motif porté ci-dessous. Un 401 sans ce message est') $lignes.Add('marqué « à confirmer » : il ne prouve pas à lui seul que la paire manque.') $lignes.Add('') $lignes.Add('| Code | Langue | Direction | Modèle testé | Détail |') $lignes.Add('|---|---|---|---|---|') foreach ($absent in $rapportAbsents) { $lignes.Add(("| {0} | {1} | {2} | `{3}` | {4} |" -f $absent.Code, $absent.Libelle, $absent.Direction, $absent.Modele, $absent.Detail)) } $lignes.Add('') $lignes.Add('## Langues ignorées (pack partiel)') $lignes.Add('') $lignes.Add('| Code | Langue | Directions présentes | Directions absentes |') $lignes.Add('|---|---|---|---|') foreach ($partiel in $rapportPartiels) { $lignes.Add(("| {0} | {1} | {2} | {3} |" -f $partiel.Code, $partiel.Libelle, $partiel.DirectionsPresentes, $partiel.DirectionsAbsentes)) } $lignes.Add('') $lignes.Add('## Échecs d''export') $lignes.Add('') $lignes.Add('| Code | Langue | Direction | Modèle | Détail |') $lignes.Add('|---|---|---|---|---|') foreach ($echecExport in $rapportEchecs) { $lignes.Add(("| {0} | {1} | {2} | `{3}` | {4} |" -f $echecExport.Code, $echecExport.Libelle, $echecExport.Direction, $echecExport.Modele, $echecExport.Detail)) } $lignes.Add('') $lignes.Add('## Étapes suivantes') $lignes.Add('') $lignes.Add('1. Valider la qualité sur le poste, pour au moins une langue, AVANT de transférer :') $lignes.Add('') $lignes.Add(' ```powershell') $lignes.Add(' dotnet run --project tools/Omika.LanguesProbe -- traduire `') $lignes.Add((' --dossier {0}\\versFrancais --code --sens versFrancais --texte "..."' -f $racine)) $lignes.Add(' ```') $lignes.Add('') $lignes.Add('2. Transférer les dossiers `\` sur le serveur, sous `Langues__RootPath`.') $lignes.Add('3. Générer le manifeste SUR LE SERVEUR (il dépend des fichiers du disque) :') $lignes.Add('') $lignes.Add(' ```powershell') $lignes.Add(' cd C:\Omika\Deploy\Deploy') $lignes.Add(' .\New-OmikaLanguesManifeste.ps1 -Version 1.0.0') $lignes.Add(' .\New-OmikaLanguesManifeste.ps1 -Verifier') $lignes.Add(' ```') $lignes.Add('') $lignes.Add('Procédure complète : `docs\LIVRAISON_MODELES_LANGUES.md`.') $cheminRapport = Join-Path $racine 'RAPPORT-LANGUES.md' $null = New-Item -ItemType Directory -Path $racine -Force $encodage = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($cheminRapport, (($lignes -join "`r`n") + "`r`n"), $encodage) Write-Host '' Write-Ok ("Rapport ecrit : {0}" -f $cheminRapport) if ($rapportEchecs.Count -gt 0) { exit 1 } exit 0