Mehdi commited on
Commit
67da08d
·
1 Parent(s): 8f2e039

fix: pre-translate non-English chunks to English before LLM inference

Browse files

French PDFs caused the model to output French despite English-only
instructions — the source tokens dominated the decoding distribution.
Fix: detect French via stop-word heuristic, translate the chunk with
a dedicated LLM call (cached per chunk), then use the English version
for question generation, MCQ and evaluation.

Also strip leading number prefixes (e.g. '3. ') from section bodies
in the JS feedback parser so mis-numbered model output renders cleanly.

Files changed (4) hide show
  1. app.py +1 -1
  2. core/evaluator.py +2 -1
  3. core/lang.py +54 -0
  4. core/questioner.py +3 -2
app.py CHANGED
@@ -639,7 +639,7 @@ BRIDGE_JS = """() => {
639
  const labels = ['','Verdict','What was good','What was missing','Model answer'];
640
  const sections = [], re = /(\\d+)\\.\\s*([^:\\n]*)[::]?\\s*([\\s\\S]*?)(?=\\n\\d+\\.|$)/g;
641
  let m;
642
- while ((m = re.exec(text)) !== null) sections.push({num:+m[1], body:m[3].trim()});
643
  body.innerHTML = sections.length >= 2
644
  ? sections.map(s => '<div class="section"><span class="section-num">'+(labels[s.num]||'Part '+s.num)+'</span><div class="section-content">'+s.body.replace(/\\n/g,'<br>')+'</div></div>').join('')
645
  : '<div class="feedback-raw">'+text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div>';
 
639
  const labels = ['','Verdict','What was good','What was missing','Model answer'];
640
  const sections = [], re = /(\\d+)\\.\\s*([^:\\n]*)[::]?\\s*([\\s\\S]*?)(?=\\n\\d+\\.|$)/g;
641
  let m;
642
+ while ((m = re.exec(text)) !== null) sections.push({num:+m[1], body:m[3].trim().replace(/^\\d+\\.\\s*/,'')});
643
  body.innerHTML = sections.length >= 2
644
  ? sections.map(s => '<div class="section"><span class="section-num">'+(labels[s.num]||'Part '+s.num)+'</span><div class="section-content">'+s.body.replace(/\\n/g,'<br>')+'</div></div>').join('')
645
  : '<div class="feedback-raw">'+text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div>';
core/evaluator.py CHANGED
@@ -17,6 +17,7 @@ Public API:
17
  """
18
 
19
  from model.llm import get_llm
 
20
 
21
  _PROMPT_EN = """\
22
  You are a patient and constructive university tutor.
@@ -44,7 +45,7 @@ def evaluate_answer(question: str, chunk: str, student_answer: str, language: st
44
  """Return structured feedback for *student_answer* given *question* and *chunk*."""
45
  llm = get_llm()
46
  prompt = _PROMPT_EN.format(
47
- chunk=chunk.strip(),
48
  question=question.strip(),
49
  answer=student_answer.strip(),
50
  )
 
17
  """
18
 
19
  from model.llm import get_llm
20
+ from core.lang import ensure_english
21
 
22
  _PROMPT_EN = """\
23
  You are a patient and constructive university tutor.
 
45
  """Return structured feedback for *student_answer* given *question* and *chunk*."""
46
  llm = get_llm()
47
  prompt = _PROMPT_EN.format(
48
+ chunk=ensure_english(chunk.strip()),
49
  question=question.strip(),
50
  answer=student_answer.strip(),
51
  )
core/lang.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/lang.py — Language detection and chunk translation utilities.
3
+
4
+ If a source chunk is not in English, the LLM struggles to follow
5
+ "write in English only" instructions because the French/Spanish tokens
6
+ in the context dominate the probability distribution at each decoding step.
7
+ Pre-translating the chunk removes this gravitational pull entirely.
8
+ """
9
+
10
+ import re
11
+ from functools import lru_cache
12
+
13
+ # Common function words that appear in French but rarely in English text
14
+ _FR_INDICATORS = frozenset([
15
+ "le", "la", "les", "de", "du", "des", "un", "une", "est", "en", "et",
16
+ "avec", "dans", "sur", "pour", "que", "qui", "se", "au", "aux", "par",
17
+ "ou", "ne", "pas", "plus", "son", "sa", "ses", "leur", "leurs", "lui",
18
+ "ils", "elles", "nous", "vous", "je", "tu", "il", "elle", "ce", "cet",
19
+ "cette", "ces", "mon", "ma", "ta", "sont", "ont", "une", "comme", "aussi",
20
+ "mais", "donc", "car", "si", "tout", "tous", "toute", "toutes", "quel",
21
+ "quelle", "quels", "quelles", "dont", "très", "aussi", "puis",
22
+ ])
23
+
24
+
25
+ def is_english(text: str) -> bool:
26
+ """Return True if *text* appears to be in English."""
27
+ words = set(re.findall(r'\b[a-zA-ZÀ-ÿ]{2,}\b', text.lower()))
28
+ french_hits = len(words & _FR_INDICATORS)
29
+ # Heuristic: ≥5 French function words → almost certainly French
30
+ return french_hits < 5
31
+
32
+
33
+ @lru_cache(maxsize=64)
34
+ def _cached_translate(chunk: str) -> str:
35
+ from model.llm import get_llm
36
+ llm = get_llm()
37
+ prompt = (
38
+ "Translate the following text to English. "
39
+ "Output ONLY the English translation, nothing else.\n\n"
40
+ "Text:\n" + chunk + "\n\nTranslation:"
41
+ )
42
+ max_tok = min(600, max(80, len(chunk.split()) * 2))
43
+ result = llm.generate(prompt, max_new_tokens=max_tok, temperature=0.1).strip()
44
+ # Sanity-check: if translation looks empty or too short, return original
45
+ if len(result) < 20:
46
+ return chunk
47
+ return result
48
+
49
+
50
+ def ensure_english(chunk: str) -> str:
51
+ """Return an English version of *chunk*, translating only if necessary."""
52
+ if is_english(chunk):
53
+ return chunk
54
+ return _cached_translate(chunk)
core/questioner.py CHANGED
@@ -16,6 +16,7 @@ Public API:
16
  import re
17
  import json
18
  from model.llm import get_llm
 
19
 
20
  _DIFFICULTY_HINT = {
21
  "Easy": "Ask for simple factual recall (What is X? Define X.).",
@@ -93,7 +94,7 @@ def parse_mcq(raw: str) -> dict:
93
  def generate_mcq(chunk: str, language: str = "English") -> dict:
94
  """Return a multiple-choice question dict generated from *chunk*."""
95
  llm = get_llm()
96
- prompt = _MCQ_TEMPLATE.format(chunk=chunk.strip(), language=language)
97
  mcq: dict = {}
98
  for _ in range(3):
99
  raw = llm.generate(prompt, temperature=0.8).strip()
@@ -123,7 +124,7 @@ def generate_question(chunk: str, language: str = "English", difficulty: str = "
123
  """Return a single study question generated from *chunk*."""
124
  llm = get_llm()
125
  prompt = _PROMPT_TEMPLATE.format(
126
- chunk=chunk.strip(),
127
  language=language,
128
  difficulty_hint=_DIFFICULTY_HINT.get(difficulty, _DIFFICULTY_HINT["Normal"]),
129
  )
 
16
  import re
17
  import json
18
  from model.llm import get_llm
19
+ from core.lang import ensure_english
20
 
21
  _DIFFICULTY_HINT = {
22
  "Easy": "Ask for simple factual recall (What is X? Define X.).",
 
94
  def generate_mcq(chunk: str, language: str = "English") -> dict:
95
  """Return a multiple-choice question dict generated from *chunk*."""
96
  llm = get_llm()
97
+ prompt = _MCQ_TEMPLATE.format(chunk=ensure_english(chunk.strip()), language=language)
98
  mcq: dict = {}
99
  for _ in range(3):
100
  raw = llm.generate(prompt, temperature=0.8).strip()
 
124
  """Return a single study question generated from *chunk*."""
125
  llm = get_llm()
126
  prompt = _PROMPT_TEMPLATE.format(
127
+ chunk=ensure_english(chunk.strip()),
128
  language=language,
129
  difficulty_hint=_DIFFICULTY_HINT.get(difficulty, _DIFFICULTY_HINT["Normal"]),
130
  )