feat: mark glossary templates as multilingual — support 11 target languages
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m30s

Templates enriched by enrich_glossary_templates.py already contain
translations for de, es, it, pt, nl, ru, ja, ko, zh, ar, fa (including
Persian). But they were labeled FR→EN, causing incorrect filtering and
warnings when translating to other languages.

Changes:
- index.json: set target_lang='multi' for all 8 templates
- GlossarySelector: treat target_language='multi' as compatible with
  any target language (no false warnings, auto-select works)
- GlossarySelector: display '🌐 MULTILINGUE' badge instead of EN flag
- glossary_routes: default target_language to 'multi' instead of 'en'
- Migration: detect existing multilingual glossaries in DB (5+ keys in
  translations JSON) and set their target_language to 'multi'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:32:27 +02:00
parent ad8ac089a4
commit c66252bed4
4 changed files with 100 additions and 40 deletions

View File

@@ -0,0 +1,54 @@
"""Set multilingual glossaries target_language to 'multi'
Revision ID: a1b2c3d4e5f6
Revises: e5b2c9d1f4a8
Create Date: 2026-05-31
Glossary templates that were enriched with multilingual translations
(via enrich_glossary_templates.py) contain translations for 11 languages
(de, es, it, pt, nl, ru, ja, ko, zh, ar, fa) in each term's translations
field. These should be marked as target_language='multi' instead of 'en'.
This migration detects glossaries whose terms have multilingual translations
and sets their target_language to 'multi'.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "a1b2c3d4e5f6"
down_revision = "e5b2c9d1f4a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Glossaries with terms containing 5+ translation keys are multilingual templates
# (enriched glossaries have 11 translations: de, es, it, pt, nl, ru, ja, ko, zh, ar, fa)
op.execute("""
UPDATE glossaries
SET target_language = 'multi'
WHERE id IN (
SELECT DISTINCT g.id
FROM glossaries g
JOIN glossary_terms gt ON gt.glossary_id = g.id
WHERE gt.translations IS NOT NULL
AND jsonb_typeof(gt.translations) = 'object'
AND (
SELECT count(*)
FROM jsonb_object_keys(gt.translations)
) >= 5
)
""")
def downgrade() -> None:
# Revert multilingual glossaries back to 'en'
op.execute("""
UPDATE glossaries
SET target_language = 'en'
WHERE target_language = 'multi'
""")

View File

@@ -1,68 +1,68 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"description": "Index des glossaires pré-définis pour la traduction. Ces modèles sont utilisés comme templates par les utilisateurs Pro.", "description": "Index des glossaires pré-définis pour la traduction. Ces modèles sont utilisés comme templates par les utilisateurs Pro. Tous les templates contiennent des traductions multilingues (de, es, it, pt, nl, ru, ja, ko, zh, ar, fa).",
"categories": { "categories": {
"legal": { "legal": {
"name": "Juridique - Français → Anglais", "name": "Juridique - Français → Multilingue",
"description": "Terminologie juridique française vers anglaise pour contrats, statuts et documents légaux", "description": "Terminologie juridique française traduite en 12 langues pour contrats, statuts et documents légaux",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "legal_fr_en.json", "file": "legal_fr_en.json",
"terms_count": 105 "terms_count": 105
}, },
"technology": { "technology": {
"name": "Technologie / IT - Français → Anglais", "name": "Technologie / IT - Français → Multilingue",
"description": "Terminologie technique et informatique française vers anglaise pour documentation, spécifications et rapports IT", "description": "Terminologie technique et informatique française traduite en 12 langues pour documentation, spécifications et rapports IT",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "tech_fr_en.json", "file": "tech_fr_en.json",
"terms_count": 155 "terms_count": 155
}, },
"finance": { "finance": {
"name": "Finance & Comptabilité - Français → Anglais", "name": "Finance & Comptabilité - Français → Multilingue",
"description": "Terminologie financière et comptable française vers anglaise pour rapports annuels, bilans et documents financiers", "description": "Terminologie financière et comptable française traduite en 12 langues pour rapports annuels, bilans et documents financiers",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "finance_fr_en.json", "file": "finance_fr_en.json",
"terms_count": 150 "terms_count": 150
}, },
"medical": { "medical": {
"name": "Médical & Santé - Français → Anglais", "name": "Médical & Santé - Français → Multilingue",
"description": "Terminologie médicale française vers anglaise pour rapports médicaux, publications scientifiques et documents de santé", "description": "Terminologie médicale française traduite en 12 langues pour rapports médicaux, publications scientifiques et documents de santé",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "medical_fr_en.json", "file": "medical_fr_en.json",
"terms_count": 165 "terms_count": 165
}, },
"marketing": { "marketing": {
"name": "Marketing & Communication - Français → Anglais", "name": "Marketing & Communication - Français → Multilingue",
"description": "Terminologie marketing et communication française vers anglaise pour campagnes, stratégies et contenus marketing", "description": "Terminologie marketing et communication française traduite en 12 langues pour campagnes, stratégies et contenus marketing",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "marketing_fr_en.json", "file": "marketing_fr_en.json",
"terms_count": 175 "terms_count": 175
}, },
"hr": { "hr": {
"name": "RH & Ressources Humaines - Français → Anglais", "name": "RH & Ressources Humaines - Français → Multilingue",
"description": "Terminologie des ressources humaines française vers anglaise pour contrats, politiques et documents RH", "description": "Terminologie des ressources humaines française traduite en 12 langues pour contrats, politiques et documents RH",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "hr_fr_en.json", "file": "hr_fr_en.json",
"terms_count": 160 "terms_count": 160
}, },
"scientific": { "scientific": {
"name": "Scientifique & Recherche - Français → Anglais", "name": "Scientifique & Recherche - Français → Multilingue",
"description": "Terminologie scientifique française vers anglaise pour publications, articles de recherche et thèses", "description": "Terminologie scientifique française traduite en 12 langues pour publications, articles de recherche et thèses",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "scientific_fr_en.json", "file": "scientific_fr_en.json",
"terms_count": 160 "terms_count": 160
}, },
"ecommerce": { "ecommerce": {
"name": "E-commerce & Vente - Français → Anglais", "name": "E-commerce & Vente - Français → Multilingue",
"description": "Terminologie e-commerce et vente française vers anglaise pour boutiques en ligne, catalogues et documents commerciaux", "description": "Terminologie e-commerce et vente française traduite en 12 langues pour boutiques en ligne, catalogues et documents commerciaux",
"source_lang": "fr", "source_lang": "fr",
"target_lang": "en", "target_lang": "multi",
"file": "ecommerce_fr_en.json", "file": "ecommerce_fr_en.json",
"terms_count": 195 "terms_count": 195
} }

View File

@@ -235,6 +235,13 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
const sourceFlag = useMemo(() => sourceLang === 'auto' ? '' : getFlag(sourceLang), [sourceLang, getFlag]); const sourceFlag = useMemo(() => sourceLang === 'auto' ? '' : getFlag(sourceLang), [sourceLang, getFlag]);
const targetFlag = useMemo(() => getFlag(targetLang), [targetLang, getFlag]); const targetFlag = useMemo(() => getFlag(targetLang), [targetLang, getFlag]);
// A glossary is compatible with the target language if:
// - its target_language exactly matches, OR
// - it's a multilingual glossary (target_language === 'multi')
const isCompatible = useCallback((gTargetLang: string) => {
return gTargetLang === targetLang || gTargetLang === 'multi';
}, [targetLang]);
const filteredGlossaries = useMemo(() => { const filteredGlossaries = useMemo(() => {
if (!filterByLang || sourceLang === 'auto') { if (!filterByLang || sourceLang === 'auto') {
return glossaries; return glossaries;
@@ -242,12 +249,12 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
return glossaries return glossaries
.filter(g => g.source_language === sourceLang) .filter(g => g.source_language === sourceLang)
.sort((a, b) => { .sort((a, b) => {
// Glossaries matching source + target come first // Compatible glossaries first, then incompatible
const aMatch = a.target_language === targetLang ? 0 : 1; const aOk = isCompatible(a.target_language) ? 0 : 1;
const bMatch = b.target_language === targetLang ? 0 : 1; const bOk = isCompatible(b.target_language) ? 0 : 1;
return aMatch - bMatch; return aOk - bOk;
}); });
}, [glossaries, filterByLang, sourceLang, targetLang]); }, [glossaries, filterByLang, sourceLang, isCompatible]);
const filteredTemplates = useMemo(() => { const filteredTemplates = useMemo(() => {
if (!filterByLang || sourceLang === 'auto') { if (!filterByLang || sourceLang === 'auto') {
@@ -285,12 +292,11 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
if (!nextVal) { if (!nextVal) {
onChange(null); onChange(null);
} else if (!glossaryId) { } else if (!glossaryId) {
// Only auto-select if a glossary matches BOTH source and target language // Auto-select first compatible glossary (exact target match or multilingual)
const matching = filteredGlossaries.find(g => g.target_language === targetLang); const matching = filteredGlossaries.find(g => isCompatible(g.target_language));
if (matching) { if (matching) {
onChange(matching.id); onChange(matching.id);
} }
// If no glossary matches the target, leave unselected — user picks manually
} }
}} }}
className={cn( className={cn(
@@ -337,8 +343,8 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</div> </div>
)} )}
{/* Mismatch Warning — target language */} {/* Mismatch Warning — target language (skip for multilingual glossaries) */}
{selected && selected.target_language && selected.target_language !== targetLang && ( {selected && selected.target_language !== 'multi' && selected.target_language && selected.target_language !== targetLang && (
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-[10px] leading-normal font-medium animate-fade-in"> <div className="flex items-start gap-1.5 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-[10px] leading-normal font-medium animate-fade-in">
<span className="shrink-0">🎯</span> <span className="shrink-0">🎯</span>
<span> <span>
@@ -365,7 +371,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</span> </span>
<span className="text-[10px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5"> <span className="text-[10px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5">
{selected {selected
? `${getFlag(selected.source_language)} ➜ ${getFlag(selected.target_language || targetLang)} • ${selected.terms_count} termes` ? `${getFlag(selected.source_language)} ➜ ${selected.target_language === 'multi' ? '🌐 MULTILINGUE' : getFlag(selected.target_language || targetLang)} • ${selected.terms_count} termes`
: (filteredGlossaries.length > 0 || filteredTemplates.length > 0 ? "Sélectionnez un glossaire" : "Aucun glossaire disponible") : (filteredGlossaries.length > 0 || filteredTemplates.length > 0 ? "Sélectionnez un glossaire" : "Aucun glossaire disponible")
} }
</span> </span>
@@ -427,7 +433,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<div className="text-left min-w-0 flex-1 pr-2"> <div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold block leading-none truncate">{g.name}</span> <span className="text-xs font-bold block leading-none truncate">{g.name}</span>
<span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1"> <span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1">
{flag} ➜ {getFlag(g.target_language || targetLang)} • {g.terms_count} termes {flag} ➜ {g.target_language === 'multi' ? '🌐 MULTILINGUE' : getFlag(g.target_language || targetLang)} • {g.terms_count} termes
</span> </span>
</div> </div>
{isSelected && <Check size={12} className="text-brand-accent shrink-0" />} {isSelected && <Check size={12} className="text-brand-accent shrink-0" />}
@@ -451,7 +457,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
); );
const isAlreadySelected = existingGlossary?.id === glossaryId; const isAlreadySelected = existingGlossary?.id === glossaryId;
const flag = getFlag(tmpl.source_lang); const flag = getFlag(tmpl.source_lang);
const tFlag = getFlag(tmpl.target_lang); const tFlag = tmpl.target_lang === 'multi' ? '🌐 MULTILINGUE' : getFlag(tmpl.target_lang);
return ( return (
<button <button

View File

@@ -57,7 +57,7 @@ def _format_glossary(glossary: Glossary) -> dict:
"id": glossary.id, "id": glossary.id,
"name": glossary.name, "name": glossary.name,
"source_language": glossary.source_language, "source_language": glossary.source_language,
"target_language": getattr(glossary, "target_language", "en") or "en", "target_language": getattr(glossary, "target_language", "multi") or "multi",
"terms": [_format_term(t) for t in glossary.terms] if glossary.terms else [], "terms": [_format_term(t) for t in glossary.terms] if glossary.terms else [],
"created_at": glossary.created_at.isoformat() if glossary.created_at else None, "created_at": glossary.created_at.isoformat() if glossary.created_at else None,
"updated_at": glossary.updated_at.isoformat() if glossary.updated_at else None, "updated_at": glossary.updated_at.isoformat() if glossary.updated_at else None,