Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion advanced_translation/models/langdetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ class OSD(models.AbstractModel):

default_threshold = 0.95
languages_langdetect = ["en", "de", "fr", "it", "es"]
# Below this many characters langdetect is unreliable: it returns unstable
# results across runs and confidently wrong ones ("Amen" is detected as Dutch).
min_length = 50

def detect_language(self, text, threshold=None):
if threshold is None:
threshold = self.default_threshold

language = self.env["res.lang.compassion"]

if not isinstance(text, str) or len(text) < 50:
if not isinstance(text, str) or len(text) < self.min_length:
return language

try:
Expand Down
12 changes: 2 additions & 10 deletions partner_communication_compassion/models/correspondence.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,5 @@ def _can_auto_send(self):
and "Final Letter" not in types
and "auto" in self.partner_id.letter_delivery_preference
)
# Only auto-send if the letter's actual content language is one the
# sponsor reads (detected from content, not the field-office stamp).
detected_lang = self._detect_letter_language()
valid = (
valid
and bool(detected_lang)
and detected_lang in self.supporter_languages_ids
)

return valid
# Don't auto-send a letter the sponsor cannot read.
return valid and self._sponsor_can_read_letter()
50 changes: 36 additions & 14 deletions sbc_compassion/models/correspondence.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,16 +459,7 @@ def _check_translation_language(self):
for letter in self.with_context(skip_lang_detect=True):
# Determine which text is analyzed
is_translation = bool(letter.translated_text or letter.english_text)
letter_text = (
letter.translated_text or letter.english_text or letter.original_text
)
# Clean text for accurate detection
clean_text = (
letter_text.strip(" \t\n\r.")
.replace(BOX_SEPARATOR, "")
.replace(PAGE_SEPARATOR, "")
.strip()
)
clean_text = letter._clean_letter_text()

if not clean_text:
# T2495 Default to English for empty B2S letters
Expand All @@ -492,17 +483,48 @@ def _check_translation_language(self):
):
letter.original_language_id = detected_lang

def _detect_letter_language(self):
"""Language the letter is actually written in. Detected from its text."""
def _clean_letter_text(self):
"""Text of the letter, stripped of separators, ready for detection."""
self.ensure_one()
text = self.translated_text or self.english_text or self.original_text or ""
clean = (
return (
text.strip(" \t\n\r.")
.replace(BOX_SEPARATOR, "")
.replace(PAGE_SEPARATOR, "")
.strip()
)
return self.env["langdetect"].detect_language(clean)

def _letter_language_verdict(self):
"""Detected language of the letter, and whether we have an opinion at all.

has_opinion is False only when the text is shorter than
langdetect.min_length; the caller then falls back to the field-office
stamp (T3371). Anything longer counts as an opinion, so a letter we
cannot read is still queued for translation (T3339).
"""
self.ensure_one()
clean = self._clean_letter_text()
lang_detector = self.env["langdetect"]
return (
lang_detector.detect_language(clean),
len(clean) >= lang_detector.min_length,
)

def _sponsor_can_read_letter(self):
"""True if the sponsor reads the language the letter is actually in.

Judged from the content when the letter holds enough text, since the
field-office stamp is not always right (T3339). Below that the detector
has no opinion, so the stamp is all we have, and trusting it beats
queueing letters nobody needs translated (T3371).
"""
self.ensure_one()
language, has_opinion = self._letter_language_verdict()
if has_opinion:
return language in self.supporter_languages_ids
return bool(self.beneficiary_language_ids & self.supporter_languages_ids) or (
self.translation_language_id in self.supporter_languages_ids
)

@api.depends("uuid")
def _compute_read_url(self):
Expand Down
1 change: 1 addition & 0 deletions sbc_compassion/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
##############################################################################

from . import test_sbc_compassion
from . import test_letter_language_verdict
72 changes: 72 additions & 0 deletions sbc_compassion/tests/test_letter_language_verdict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
##############################################################################
#
# Copyright (C) 2026 Compassion CH (http://www.compassion.ch)
# Releasing children from poverty in Jesus' name
#
# The licence is in the file __manifest__.py
#
##############################################################################

from langdetect import DetectorFactory

from odoo.tests import TransactionCase

SHORT_CAPTION = "Salut, C'est Martial. Juste un bonjour!"
LONG_ENGLISH = (
"Dear sponsor, thank you very much for your letter and for the gift you "
"sent me. I am doing well at school. God bless you."
)
LONG_SWAHILI = (
"Habari yako mlezi wangu, asante sana kwa barua yako na zawadi "
"uliyonitumia. Mimi ni mzima wa afya. Mungu akubariki sana."
)


class TestLetterLanguageVerdict(TransactionCase):
"""Pins the verdict the B2S translation gate is built on (T3339 / T3371).

Covers `_letter_language_verdict` only. `_sponsor_can_read_letter` needs a
persisted letter, because `supporter_languages_ids` is related to
`partner_id.spoken_lang_ids` and comes back NewId-wrapped on an in-memory
record, and building one needs `BaseSponsorshipTest`, whose `setUpClass` is
currently broken for all 13 test classes that use it.
"""

def setUp(self):
super().setUp()
# langdetect samples randomly and runs unseeded in production, so the
# same text occasionally misses the confidence threshold (~0.1% of runs
# for the English text below). Pin the seed here so these tests exercise
# verdict handling rather than detector luck.
previous_seed = DetectorFactory.seed
DetectorFactory.seed = 0
self.addCleanup(setattr, DetectorFactory, "seed", previous_seed)

def _letter(self, text, field="translated_text"):
return self.env["correspondence"].new(
{"page_ids": [(0, 0, {"paragraph_ids": [(0, 0, {field: text})]})]}
)

def test_short_text_gives_no_opinion(self):
"""Photo captions are too short to judge: the gate must fall back."""
language, has_opinion = self._letter(SHORT_CAPTION)._letter_language_verdict()
self.assertFalse(language)
self.assertFalse(has_opinion)

def test_long_text_is_detected(self):
language, has_opinion = self._letter(LONG_ENGLISH)._letter_language_verdict()
self.assertTrue(has_opinion)
self.assertEqual(
language, self.env.ref("advanced_translation.lang_compassion_english")
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

def test_long_untranslatable_text_still_gives_an_opinion(self):
"""A language we do not handle is an answer, not an absence of one.

Without this the letter falls back to the field-office stamp and can
reach the sponsor untranslated (T3339).
"""
letter = self._letter(LONG_SWAHILI, field="original_text")
language, has_opinion = letter._letter_language_verdict()
self.assertFalse(language)
self.assertTrue(has_opinion)
9 changes: 2 additions & 7 deletions sbc_translation/models/correspondence.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,8 @@ def process_letter(self):
if not force_publish:
letter._check_translation_language()

# Can sponser read the letter? Decide from the letter's ACTUAL
# content language, not the field-office TranslationLanguage stamp.
# If it can't be determined, fail safe to translation.
detected_lang = letter._detect_letter_language()
langs_match = (
bool(detected_lang) and detected_lang in letter.supporter_languages_ids
)
# Can sponser read the letter?
langs_match = letter._sponsor_can_read_letter()

# Is the letter still in the translation process?
translation_hold = (
Expand Down
Loading