fix(glossary): resolve data loss for non-FR/EN languages, fix prompt injection reference notes, and classic mode label wording
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m25s

This commit is contained in:
2026-06-20 18:09:13 +02:00
parent 1fe714aa1a
commit c17dd2c6e1
5 changed files with 91 additions and 37 deletions

View File

@@ -37,25 +37,24 @@ const MAX_TERMS = 500;
*/ */
function getDisplaySource( function getDisplaySource(
term: { source: string; target: string; translations?: Record<string, string> | null }, term: { source: string; target: string; translations?: Record<string, string> | null },
lang: string lang: string,
glossarySourceLang: string
): string { ): string {
if (!lang || lang === 'multi') return ''; if (!lang || lang === 'multi') return '';
if (lang === 'fr') return term.source; if (lang === glossarySourceLang) return term.source;
if (lang === 'en') return term.target;
const translations = term.translations || {}; const translations = term.translations || {};
return translations[lang] || ''; return translations[lang] || '';
} }
/** Target term in the chosen language. Mêmes règles que getDisplaySource, /** Target term in the chosen language.
* mais l'inverse : EN est le défaut (champ target), FR vient de source.
*/ */
function getDisplayTarget( function getDisplayTarget(
term: { source: string; target: string; translations?: Record<string, string> | null }, term: { source: string; target: string; translations?: Record<string, string> | null },
lang: string lang: string,
glossaryTargetLang: string
): string { ): string {
if (!lang || lang === 'multi') return term.target; // multi = défaut = EN if (!lang) return '';
if (lang === 'en') return term.target; if (lang === 'multi' || lang === glossaryTargetLang) return term.target;
if (lang === 'fr') return term.source;
const translations = term.translations || {}; const translations = term.translations || {};
return translations[lang] || ''; return translations[lang] || '';
} }
@@ -145,39 +144,82 @@ export default function GlossaryDetailPage() {
}; };
const handleTermChange = (index: number, field: 'source' | 'target', value: string) => { const handleTermChange = (index: number, field: 'source' | 'target', value: string) => {
if (!glossary) return;
setTerms(terms.map((t, i) => { setTerms(terms.map((t, i) => {
if (i !== index) return t; if (i !== index) return t;
const translations = { ...(t.translations || {}) } as Record<string, string>; const translations = { ...(t.translations || {}) } as Record<string, string>;
// FR et EN sont dans les champs legacy (term.source / term.target), const editLang = field === 'source' ? sourceLanguage : targetLanguage;
// les 11 autres langues dans translations[lang].
// Pour l'édition, on écrit au bon endroit selon la langue choisie.
const isFr = field === 'source' ? sourceLanguage === 'fr' : targetLanguage === 'fr';
const isEn = field === 'source' ? sourceLanguage === 'en' : targetLanguage === 'en';
const otherLang = field === 'source' ? sourceLanguage : targetLanguage;
if (isFr) { if (field === 'source') {
// Écriture du français → champ source (legacy) if (editLang === glossary.source_language) {
return { ...t, source: value }; return { ...t, source: value };
} else {
if (editLang && editLang !== 'multi') {
translations[editLang] = value;
}
return { ...t, translations };
}
} else {
if (editLang === 'multi' || editLang === glossary.target_language) {
return { ...t, target: value };
} else {
if (editLang && editLang !== 'multi') {
translations[editLang] = value;
}
return { ...t, translations };
}
} }
if (isEn || !otherLang || otherLang === 'multi') {
// Écriture de l'anglais (ou défaut multi) → champ target (legacy)
return { ...t, target: value };
}
// Autre langue → translations[otherLang]
translations[otherLang] = value;
return { ...t, source: t.source, target: t.target, translations };
})); }));
}; };
const migrateTerms = (
currentTerms: GlossaryTermInput[],
oldSrc: string,
newSrc: string,
oldTgt: string,
newTgt: string
): GlossaryTermInput[] => {
if (!glossary) return currentTerms;
return currentTerms.map(t => {
const languagesData: Record<string, string> = { ...(t.translations || {}) };
const oldSrcVal = oldSrc === glossary.source_language ? t.source : (t.translations?.[oldSrc] || '');
const oldTgtVal = oldTgt === 'multi' || oldTgt === glossary.target_language ? t.target : (t.translations?.[oldTgt] || '');
if (oldSrc && oldSrc !== 'multi') {
languagesData[oldSrc] = oldSrcVal;
}
if (oldTgt) {
const langKey = oldTgt === 'multi' ? 'en' : oldTgt;
languagesData[langKey] = oldTgtVal;
}
const newSourceVal = languagesData[newSrc] || '';
const targetLangKey = newTgt === 'multi' ? 'en' : newTgt;
const newTargetVal = languagesData[targetLangKey] || '';
delete languagesData[newSrc];
delete languagesData[targetLangKey];
return {
source: newSourceVal,
target: newTargetVal,
translations: languagesData
};
});
};
const handleSourceLanguageChange = (newLang: string) => { const handleSourceLanguageChange = (newLang: string) => {
const updated = migrateTerms(terms, sourceLanguage, newLang, targetLanguage, targetLanguage);
setSourceLanguage(newLang); setSourceLanguage(newLang);
// No remap : l'affichage lit translations[newLang] à la volée. setTerms(updated);
}; };
const handleTargetLanguageChange = (newLang: string) => { const handleTargetLanguageChange = (newLang: string) => {
const updated = migrateTerms(terms, sourceLanguage, sourceLanguage, targetLanguage, newLang);
setTargetLanguage(newLang); setTargetLanguage(newLang);
// No remap : l'affichage lit translations[newLang] à la volée. setTerms(updated);
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -520,7 +562,7 @@ export default function GlossaryDetailPage() {
> >
<td className="px-6 py-1.5"> <td className="px-6 py-1.5">
<input <input
value={getDisplaySource(term, sourceLanguage)} value={getDisplaySource(term, sourceLanguage, glossary.source_language)}
onChange={(e) => handleTermChange(term._index, 'source', e.target.value)} onChange={(e) => handleTermChange(term._index, 'source', e.target.value)}
disabled={isUpdating} disabled={isUpdating}
placeholder={t('glossaries.detail.sourcePlaceholder') || 'terme source'} placeholder={t('glossaries.detail.sourcePlaceholder') || 'terme source'}
@@ -529,7 +571,7 @@ export default function GlossaryDetailPage() {
</td> </td>
<td className="px-3 py-1.5"> <td className="px-3 py-1.5">
<input <input
value={getDisplayTarget(term, targetLanguage)} value={getDisplayTarget(term, targetLanguage, glossary.target_language)}
onChange={(e) => handleTermChange(term._index, 'target', e.target.value)} onChange={(e) => handleTermChange(term._index, 'target', e.target.value)}
disabled={isUpdating} disabled={isUpdating}
placeholder={t('glossaries.detail.targetPlaceholder') || 'terme cible'} placeholder={t('glossaries.detail.targetPlaceholder') || 'terme cible'}

View File

@@ -18,7 +18,7 @@
"translate.glossary.noGlossaryForPair": "No glossary for", "translate.glossary.noGlossaryForPair": "No glossary for",
"translate.glossary.noGlossaries": "No glossaries yet", "translate.glossary.noGlossaries": "No glossaries yet",
"translate.glossary.loading": "Loading...", "translate.glossary.loading": "Loading...",
"translate.glossary.classicMode": "Neutral engine without glossary (AI only)", "translate.glossary.classicMode": "Classic engine (no glossary - Pro LLM mode required)",
"translate.glossary.selectPlaceholder": "Select a glossary...", "translate.glossary.selectPlaceholder": "Select a glossary...",
"translate.glossary.multilingual": "MULTILINGUAL", "translate.glossary.multilingual": "MULTILINGUAL",
"translate.glossary.noGlossaryAvailable": "No glossary available", "translate.glossary.noGlossaryAvailable": "No glossary available",
@@ -38,7 +38,7 @@
"translate.glossary.sourceTerm": "Source term", "translate.glossary.sourceTerm": "Source term",
"translate.glossary.translation": "Translation", "translate.glossary.translation": "Translation",
"translate.glossary.addTerm": "Add term", "translate.glossary.addTerm": "Add term",
"translate.glossary.disabledMode": "Neutral engine without glossary applied", "translate.glossary.disabledMode": "Classic engine without glossary applied",
"translate.glossary.addTermError": "Error adding term", "translate.glossary.addTermError": "Error adding term",
"translate.glossary.networkError": "Network error", "translate.glossary.networkError": "Network error",
"translate.glossary.importFailed": "Import failed ({status})", "translate.glossary.importFailed": "Import failed ({status})",

View File

@@ -18,7 +18,7 @@
"translate.glossary.noGlossaryForPair": "Aucun glossaire pour", "translate.glossary.noGlossaryForPair": "Aucun glossaire pour",
"translate.glossary.noGlossaries": "Aucun glossaire", "translate.glossary.noGlossaries": "Aucun glossaire",
"translate.glossary.loading": "Chargement...", "translate.glossary.loading": "Chargement...",
"translate.glossary.classicMode": "Moteur neutre sans glossaire (IA uniquement)", "translate.glossary.classicMode": "Moteur classique (sans glossaire - Mode Pro LLM requis)",
"translate.glossary.selectPlaceholder": "Sélectionner un glossaire...", "translate.glossary.selectPlaceholder": "Sélectionner un glossaire...",
"translate.glossary.multilingual": "MULTILINGUE", "translate.glossary.multilingual": "MULTILINGUE",
"translate.glossary.noGlossaryAvailable": "Aucun glossaire disponible", "translate.glossary.noGlossaryAvailable": "Aucun glossaire disponible",
@@ -38,7 +38,7 @@
"translate.glossary.sourceTerm": "Terme Source", "translate.glossary.sourceTerm": "Terme Source",
"translate.glossary.translation": "Traduction", "translate.glossary.translation": "Traduction",
"translate.glossary.addTerm": "Ajouter le terme", "translate.glossary.addTerm": "Ajouter le terme",
"translate.glossary.disabledMode": "Moteur neutre sans glossaire appliqué", "translate.glossary.disabledMode": "Moteur classique sans glossaire appliqué",
"translate.glossary.addTermError": "Erreur lors de l'ajout du terme", "translate.glossary.addTermError": "Erreur lors de l'ajout du terme",
"translate.glossary.networkError": "Erreur réseau", "translate.glossary.networkError": "Erreur réseau",
"translate.glossary.importFailed": "Échec de l'importation ({status})", "translate.glossary.importFailed": "Échec de l'importation ({status})",

View File

@@ -1048,12 +1048,14 @@ async def _run_translation_job(
# Story 3.10: Retrieve and format glossary terms for LLM prompt # Story 3.10: Retrieve and format glossary terms for LLM prompt
glossary_terms = None glossary_terms = None
glossary_source_lang = "fr" glossary_source_lang = "fr"
glossary_target_lang = "multi"
if glossary_id and user_id: if glossary_id and user_id:
try: try:
glossary_data = get_glossary_terms(glossary_id, user_id) glossary_data = get_glossary_terms(glossary_id, user_id)
glossary_terms = glossary_data["terms"] glossary_terms = glossary_data["terms"]
glossary_source_lang = glossary_data.get("source_language", "fr") glossary_source_lang = glossary_data.get("source_language", "fr")
logger.info(f"Job {job_id}: Loaded {len(glossary_terms)} glossary terms (source: {glossary_source_lang})") glossary_target_lang = glossary_data.get("target_language", "multi")
logger.info(f"Job {job_id}: Loaded {len(glossary_terms)} glossary terms (source: {glossary_source_lang}, target: {glossary_target_lang})")
except GlossaryNotFoundError as e: except GlossaryNotFoundError as e:
tracker.set_error(str(e)) tracker.set_error(str(e))
logger.error(f"Job {job_id}: Glossary error - {e}") logger.error(f"Job {job_id}: Glossary error - {e}")
@@ -1078,6 +1080,7 @@ async def _run_translation_job(
full_prompt = build_full_prompt( full_prompt = build_full_prompt(
effective_prompt, glossary_terms, effective_prompt, glossary_terms,
source_lang=glossary_source_lang, target_lang=target_lang, source_lang=glossary_source_lang, target_lang=target_lang,
glossary_target_lang=glossary_target_lang,
) )
from services.providers.google_provider import GoogleTranslationProvider from services.providers.google_provider import GoogleTranslationProvider

View File

@@ -122,6 +122,7 @@ def format_glossary_for_prompt(
terms: List[Dict[str, str]], terms: List[Dict[str, str]],
source_lang: str = "fr", source_lang: str = "fr",
target_lang: str = "en", target_lang: str = "en",
glossary_target_lang: str = "multi",
) -> str: ) -> str:
""" """
Format glossary terms for injection into an LLM system prompt. Format glossary terms for injection into an LLM system prompt.
@@ -135,6 +136,7 @@ def format_glossary_for_prompt(
terms: List of dicts with 'source', 'target', and optional 'translations' terms: List of dicts with 'source', 'target', and optional 'translations'
source_lang: ISO code of the source language source_lang: ISO code of the source language
target_lang: ISO code of the target language target_lang: ISO code of the target language
glossary_target_lang: ISO code of the glossary's target language configuration
Returns: Returns:
Formatted string for LLM prompt Formatted string for LLM prompt
@@ -166,8 +168,11 @@ def format_glossary_for_prompt(
elif default_target: elif default_target:
source_escaped = source.replace("'", "\\'") source_escaped = source.replace("'", "\\'")
target_escaped = default_target.replace("'", "\\'") target_escaped = default_target.replace("'", "\\'")
lines.append(f"- '{source_escaped}''{target_escaped}' (EN reference, adapt to {target_lang})") if glossary_target_lang == target_lang:
has_fallback = True lines.append(f"- '{source_escaped}''{target_escaped}'")
else:
lines.append(f"- '{source_escaped}''{target_escaped}' (EN reference, adapt to {target_lang})")
has_fallback = True
# If neither specific nor default, skip the term # If neither specific nor default, skip the term
if not any(line.startswith("- ") for line in lines): if not any(line.startswith("- ") for line in lines):
@@ -192,6 +197,7 @@ def build_full_prompt(
glossary_terms: Optional[List[Dict[str, str]]], glossary_terms: Optional[List[Dict[str, str]]],
source_lang: str = "fr", source_lang: str = "fr",
target_lang: str = "en", target_lang: str = "en",
glossary_target_lang: str = "multi",
) -> str: ) -> str:
""" """
Build the complete prompt combining custom prompt and glossary. Build the complete prompt combining custom prompt and glossary.
@@ -201,6 +207,7 @@ def build_full_prompt(
glossary_terms: Optional list of glossary terms glossary_terms: Optional list of glossary terms
source_lang: ISO code of the source language source_lang: ISO code of the source language
target_lang: ISO code of the target language target_lang: ISO code of the target language
glossary_target_lang: ISO code of the glossary's target language configuration
Returns: Returns:
Combined prompt string Combined prompt string
@@ -211,7 +218,9 @@ def build_full_prompt(
parts.append(custom_prompt) parts.append(custom_prompt)
if glossary_terms: if glossary_terms:
glossary_prompt = format_glossary_for_prompt(glossary_terms, source_lang, target_lang) glossary_prompt = format_glossary_for_prompt(
glossary_terms, source_lang, target_lang, glossary_target_lang
)
if glossary_prompt: if glossary_prompt:
parts.append(glossary_prompt) parts.append(glossary_prompt)