Files
Omika/scripts/Exporter-Packs-Langues.ps1
2026-10-23 18:46:50 +02:00

1617 lines
78 KiB
PowerShell

# ============================================================
# 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-<code>-fr (langue -> francais) et
# Helsinki-NLP/opus-mt-fr-<code> (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 <code>\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 : <RacinePacks>\<code>\<sens>\.
# 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 : <RacinePacks>\.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 <code>\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') } # <langue> -> francais
@{ Sens = 'depuisFrancais'; Gabarits = @('Helsinki-NLP/opus-mt-fr-{0}', 'Helsinki-NLP/opus-mt-tc-big-fr-{0}') } # francais -> <langue>
)
# 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 <HF_HOME>\hub\models--<org>--<nom>.
# 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 <Cache>\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 <code>\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 : <langue>->fr et fr-><langue>) ---'
$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 <code>\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}\<code>\<sens>\ (~{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-<code>-<sens>\ (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}\<code>\versFrancais --code <code> --sens versFrancais --texte "..."' -f $racine))
$lignes.Add(' ```')
$lignes.Add('')
$lignes.Add('2. Transférer les dossiers `<code>\` 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