Spaces:
Running on Zero
Running on Zero
Mehdi commited on
Commit ·
67da08d
1
Parent(s): 8f2e039
fix: pre-translate non-English chunks to English before LLM inference
Browse filesFrench 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.
- app.py +1 -1
- core/evaluator.py +2 -1
- core/lang.py +54 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>')+'</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,'&').replace(/</g,'<').replace(/>/g,'>')+'</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 |
)
|