ClaudBarbara commited on
Commit
77f086b
·
verified ·
1 Parent(s): 3dadb97

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +280 -37
app.py CHANGED
@@ -1,3 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from flask import Flask, render_template, request, jsonify
2
  from transformers import AutoModelForSeq2SeqLM, NllbTokenizerFast
3
  import torch
@@ -5,10 +21,30 @@ import fitz
5
  import re
6
  import unicodedata
7
  import time
 
 
8
  from sacremoses import MosesPunctNormalizer
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  app = Flask(__name__)
11
 
 
 
 
 
12
  mpn = MosesPunctNormalizer(lang="en")
13
  mpn.substitutions = [(re.compile(r), sub) for r, sub in mpn.substitutions]
14
 
@@ -23,29 +59,90 @@ def get_non_printing_char_replacer(replace_by: str = " "):
23
  replace_nonprint = get_non_printing_char_replacer(" ")
24
 
25
  def preprocess_text(text: str) -> str:
 
26
  clean = mpn.normalize(text)
27
  clean = replace_nonprint(clean)
28
  clean = unicodedata.normalize("NFKC", clean)
29
  return clean
30
 
31
- print("Loading model...")
 
 
 
 
 
32
  MODEL_ID = "ClaudBarbara/Open_Access_Khmer"
33
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID)
34
  tokenizer = NllbTokenizerFast.from_pretrained(MODEL_ID)
35
- print("Model loaded!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- CONFIDENCE_THRESHOLD = 70 # Below this = human review needed
 
 
38
 
39
- def segment_text(text, src_lang):
 
40
  if src_lang == "khm_Khmr":
 
41
  sentences = re.split(r'(?<=[។៖])\s*', text)
42
  else:
 
43
  sentences = re.split(r'(?<=[.!?])\s+', text)
44
  return [s.strip() for s in sentences if s.strip()]
45
 
46
- def translate_batch(texts, src_lang, tgt_lang):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  if not texts:
48
- return [], []
49
 
50
  tokenizer.src_lang = src_lang
51
  inputs = tokenizer(
@@ -62,56 +159,151 @@ def translate_batch(texts, src_lang, tgt_lang):
62
  forced_bos_token_id=tokenizer.convert_tokens_to_ids(tgt_lang),
63
  max_new_tokens=int(32 + 3 * inputs.input_ids.shape[1]),
64
  num_beams=4,
65
- early_stopping=True,
66
- return_dict_in_generate=True,
67
- output_scores=True
68
  )
69
 
70
- translations = tokenizer.batch_decode(outputs.sequences, skip_special_tokens=True)
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- # Extract confidence scores
73
- if hasattr(outputs, 'sequences_scores') and outputs.sequences_scores is not None:
74
- scores = torch.sigmoid(outputs.sequences_scores).tolist()
75
- else:
76
- scores = [0.85] * len(texts)
 
77
 
78
- return translations, scores
79
-
80
- def translate_long(text, src_lang, tgt_lang, batch_size=8):
81
  start_time = time.time()
82
 
 
83
  clean_text = preprocess_text(text)
84
  sentences = segment_text(clean_text, src_lang)
85
 
86
  if not sentences:
87
- return "", {}
88
 
 
89
  translated_parts = []
90
- all_scores = []
91
-
92
  for i in range(0, len(sentences), batch_size):
93
  batch = sentences[i:i + batch_size]
94
- translations, scores = translate_batch(batch, src_lang, tgt_lang)
95
  translated_parts.extend(translations)
96
- all_scores.extend(scores)
97
 
98
  result = " ".join(translated_parts)
99
  elapsed = time.time() - start_time
100
 
101
- avg_confidence = (sum(all_scores) / len(all_scores) * 100) if all_scores else 0
102
- min_confidence = (min(all_scores) * 100) if all_scores else 0
103
 
104
- metrics = {
105
- "confidence": round(avg_confidence, 1),
106
- "min_confidence": round(min_confidence, 1),
107
- "needs_review": avg_confidence < CONFIDENCE_THRESHOLD,
108
- "time_seconds": round(elapsed, 2),
109
- "sentences": len(sentences)
110
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  return result, metrics
113
 
114
- def extract_pdf_text(pdf_file):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  try:
116
  pdf_bytes = pdf_file.read()
117
  doc = fitz.open(stream=pdf_bytes, filetype="pdf")
@@ -120,15 +312,35 @@ def extract_pdf_text(pdf_file):
120
  text += page.get_text()
121
  doc.close()
122
  return text.strip()
123
- except:
 
124
  return None
125
 
 
 
 
 
 
126
  @app.route("/")
127
  def index():
 
128
  return render_template("index.html")
129
 
 
130
  @app.route("/translate", methods=["POST"])
131
  def translate_endpoint():
 
 
 
 
 
 
 
 
 
 
 
 
132
  data = request.json
133
  text = data.get("text", "")
134
  direction = data.get("direction", "en-km")
@@ -140,12 +352,27 @@ def translate_endpoint():
140
 
141
  try:
142
  result, metrics = translate_long(text, src_lang, tgt_lang)
143
- return jsonify({"success": True, "translation": result, "metrics": metrics})
 
 
 
 
144
  except Exception as e:
145
- return jsonify({"success": False, "error": str(e)})
 
 
 
 
 
146
 
147
  @app.route("/upload-pdf", methods=["POST"])
148
  def upload_pdf():
 
 
 
 
 
 
149
  if 'file' not in request.files:
150
  return jsonify({"success": False, "error": "No file uploaded"})
151
 
@@ -162,5 +389,21 @@ def upload_pdf():
162
  else:
163
  return jsonify({"success": False, "error": "Could not extract text"})
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  if __name__ == "__main__":
166
- app.run(host="0.0.0.0", port=7860)
 
 
1
+ """
2
+ Khmer Legal Bridge - Translation API
3
+ =====================================
4
+
5
+ Flask application with COMETKiwi-based confidence scoring.
6
+
7
+ Features:
8
+ - Bidirectional EN↔KM translation using fine-tuned NLLB-200
9
+ - Scientific confidence scoring with COMETKiwi
10
+ - PDF text extraction
11
+ - Privacy-first design (zero retention)
12
+
13
+ Author: Khmer Legal Bridge Project
14
+ License: MIT
15
+ """
16
+
17
  from flask import Flask, render_template, request, jsonify
18
  from transformers import AutoModelForSeq2SeqLM, NllbTokenizerFast
19
  import torch
 
21
  import re
22
  import unicodedata
23
  import time
24
+ import logging
25
+ import os
26
  from sacremoses import MosesPunctNormalizer
27
 
28
+ # Import confidence scoring module
29
+ from confidence_scoring_v2 import (
30
+ TransparencyScorer,
31
+ DEFAULT_LEGAL_GLOSSARY,
32
+ ConfidenceResult
33
+ )
34
+
35
+ # Configure logging
36
+ logging.basicConfig(
37
+ level=logging.INFO,
38
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
39
+ )
40
+ logger = logging.getLogger(__name__)
41
+
42
  app = Flask(__name__)
43
 
44
+ # ============================================================================
45
+ # Text Preprocessing
46
+ # ============================================================================
47
+
48
  mpn = MosesPunctNormalizer(lang="en")
49
  mpn.substitutions = [(re.compile(r), sub) for r, sub in mpn.substitutions]
50
 
 
59
  replace_nonprint = get_non_printing_char_replacer(" ")
60
 
61
  def preprocess_text(text: str) -> str:
62
+ """Clean and normalize text for translation."""
63
  clean = mpn.normalize(text)
64
  clean = replace_nonprint(clean)
65
  clean = unicodedata.normalize("NFKC", clean)
66
  return clean
67
 
68
+
69
+ # ============================================================================
70
+ # Model Loading
71
+ # ============================================================================
72
+
73
+ logger.info("Loading translation model...")
74
  MODEL_ID = "ClaudBarbara/Open_Access_Khmer"
75
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID)
76
  tokenizer = NllbTokenizerFast.from_pretrained(MODEL_ID)
77
+ logger.info("Translation model loaded!")
78
+
79
+ # Configuration
80
+ USE_COMET = os.environ.get("USE_COMET", "true").lower() == "true"
81
+ USE_DETAILED_SCORING = os.environ.get("DETAILED_SCORING", "true").lower() == "true"
82
+
83
+ # Initialize confidence scorer (lazy loading for COMETKiwi)
84
+ confidence_scorer = None
85
+
86
+ def get_confidence_scorer():
87
+ """Lazy initialization of confidence scorer."""
88
+ global confidence_scorer
89
+ if confidence_scorer is None:
90
+ logger.info(f"Initializing confidence scorer (COMETKiwi: {USE_COMET})")
91
+ confidence_scorer = TransparencyScorer(
92
+ translator_func=translate_simple,
93
+ glossary=DEFAULT_LEGAL_GLOSSARY,
94
+ use_comet=USE_COMET,
95
+ use_back_translation=True,
96
+ use_terminology=True
97
+ )
98
+ return confidence_scorer
99
+
100
 
101
+ # ============================================================================
102
+ # Translation Functions
103
+ # ============================================================================
104
 
105
+ def segment_text(text: str, src_lang: str) -> list:
106
+ """Segment text into sentences for batch processing."""
107
  if src_lang == "khm_Khmr":
108
+ # Khmer sentence boundaries
109
  sentences = re.split(r'(?<=[។៖])\s*', text)
110
  else:
111
+ # English sentence boundaries
112
  sentences = re.split(r'(?<=[.!?])\s+', text)
113
  return [s.strip() for s in sentences if s.strip()]
114
 
115
+
116
+ def translate_simple(text: str, src_lang: str, tgt_lang: str) -> str:
117
+ """
118
+ Simple translation without confidence scoring.
119
+ Used for back-translation verification.
120
+ """
121
+ tokenizer.src_lang = src_lang
122
+ inputs = tokenizer(
123
+ text,
124
+ return_tensors='pt',
125
+ padding=True,
126
+ truncation=True,
127
+ max_length=512
128
+ )
129
+
130
+ with torch.no_grad():
131
+ outputs = model.generate(
132
+ **inputs,
133
+ forced_bos_token_id=tokenizer.convert_tokens_to_ids(tgt_lang),
134
+ max_new_tokens=int(32 + 3 * inputs.input_ids.shape[1]),
135
+ num_beams=4,
136
+ early_stopping=True
137
+ )
138
+
139
+ return tokenizer.decode(outputs[0], skip_special_tokens=True)
140
+
141
+
142
+ def translate_batch(texts: list, src_lang: str, tgt_lang: str) -> list:
143
+ """Translate a batch of texts efficiently."""
144
  if not texts:
145
+ return []
146
 
147
  tokenizer.src_lang = src_lang
148
  inputs = tokenizer(
 
159
  forced_bos_token_id=tokenizer.convert_tokens_to_ids(tgt_lang),
160
  max_new_tokens=int(32 + 3 * inputs.input_ids.shape[1]),
161
  num_beams=4,
162
+ early_stopping=True
 
 
163
  )
164
 
165
+ return tokenizer.batch_decode(outputs, skip_special_tokens=True)
166
+
167
+
168
+ def translate_long(
169
+ text: str,
170
+ src_lang: str,
171
+ tgt_lang: str,
172
+ batch_size: int = 8,
173
+ compute_confidence: bool = True
174
+ ) -> tuple:
175
+ """
176
+ Translate long text with sentence segmentation and confidence scoring.
177
 
178
+ Args:
179
+ text: Input text
180
+ src_lang: Source language code
181
+ tgt_lang: Target language code
182
+ batch_size: Batch size for processing
183
+ compute_confidence: Whether to compute detailed confidence
184
 
185
+ Returns:
186
+ Tuple of (translation, metrics_dict)
187
+ """
188
  start_time = time.time()
189
 
190
+ # Preprocess
191
  clean_text = preprocess_text(text)
192
  sentences = segment_text(clean_text, src_lang)
193
 
194
  if not sentences:
195
+ return "", {"error": "No text to translate"}
196
 
197
+ # Translate in batches
198
  translated_parts = []
 
 
199
  for i in range(0, len(sentences), batch_size):
200
  batch = sentences[i:i + batch_size]
201
+ translations = translate_batch(batch, src_lang, tgt_lang)
202
  translated_parts.extend(translations)
 
203
 
204
  result = " ".join(translated_parts)
205
  elapsed = time.time() - start_time
206
 
207
+ # Compute confidence score
208
+ direction = "en2km" if src_lang == "eng_Latn" else "km2en"
209
 
210
+ if compute_confidence and USE_COMET:
211
+ try:
212
+ scorer = get_confidence_scorer()
213
+
214
+ # For long texts, sample representative sentences for scoring
215
+ if len(sentences) > 5:
216
+ # Score first, middle, and last sentences
217
+ sample_indices = [0, len(sentences)//2, -1]
218
+ sample_scores = []
219
+
220
+ for idx in sample_indices:
221
+ src_sent = sentences[idx]
222
+ tgt_sent = translated_parts[idx]
223
+
224
+ conf_result = scorer.score(
225
+ src_sent, tgt_sent, direction,
226
+ detailed=USE_DETAILED_SCORING
227
+ )
228
+ sample_scores.append(conf_result.overall_score)
229
+
230
+ avg_score = sum(sample_scores) / len(sample_scores)
231
+ min_score = min(sample_scores)
232
+
233
+ # Use most conservative estimate
234
+ confidence_score = min(avg_score, min_score + 0.1)
235
+
236
+ else:
237
+ # Score entire translation
238
+ conf_result = scorer.score(
239
+ clean_text, result, direction,
240
+ detailed=USE_DETAILED_SCORING
241
+ )
242
+ confidence_score = conf_result.overall_score
243
+
244
+ # Determine review recommendation
245
+ needs_review = confidence_score < 0.75
246
+ quality_level = (
247
+ "excellent" if confidence_score >= 0.85 else
248
+ "good" if confidence_score >= 0.70 else
249
+ "acceptable" if confidence_score >= 0.55 else
250
+ "low" if confidence_score >= 0.40 else
251
+ "very_low"
252
+ )
253
+
254
+ metrics = {
255
+ "confidence": round(confidence_score * 100, 1),
256
+ "quality_level": quality_level,
257
+ "needs_review": needs_review,
258
+ "time_seconds": round(elapsed, 2),
259
+ "sentences": len(sentences),
260
+ "method": "comet_kiwi"
261
+ }
262
+
263
+ except Exception as e:
264
+ logger.error(f"Confidence scoring failed: {e}")
265
+ # Fallback to lightweight scoring
266
+ metrics = compute_lightweight_metrics(
267
+ clean_text, result, direction, elapsed, len(sentences)
268
+ )
269
+ else:
270
+ # Use lightweight scoring
271
+ metrics = compute_lightweight_metrics(
272
+ clean_text, result, direction, elapsed, len(sentences)
273
+ )
274
 
275
  return result, metrics
276
 
277
+
278
+ def compute_lightweight_metrics(
279
+ source: str,
280
+ translation: str,
281
+ direction: str,
282
+ elapsed: float,
283
+ num_sentences: int
284
+ ) -> dict:
285
+ """
286
+ Compute lightweight confidence metrics without COMETKiwi.
287
+ """
288
+ scorer = get_confidence_scorer()
289
+ conf_result = scorer.score_fast(source, translation, direction)
290
+
291
+ return {
292
+ "confidence": round(conf_result.overall_score * 100, 1),
293
+ "quality_level": conf_result.quality_level,
294
+ "needs_review": conf_result.human_review_recommended,
295
+ "time_seconds": round(elapsed, 2),
296
+ "sentences": num_sentences,
297
+ "method": "lightweight"
298
+ }
299
+
300
+
301
+ # ============================================================================
302
+ # PDF Extraction
303
+ # ============================================================================
304
+
305
+ def extract_pdf_text(pdf_file) -> str:
306
+ """Extract text from uploaded PDF file."""
307
  try:
308
  pdf_bytes = pdf_file.read()
309
  doc = fitz.open(stream=pdf_bytes, filetype="pdf")
 
312
  text += page.get_text()
313
  doc.close()
314
  return text.strip()
315
+ except Exception as e:
316
+ logger.error(f"PDF extraction failed: {e}")
317
  return None
318
 
319
+
320
+ # ============================================================================
321
+ # API Routes
322
+ # ============================================================================
323
+
324
  @app.route("/")
325
  def index():
326
+ """Serve the main translation interface."""
327
  return render_template("index.html")
328
 
329
+
330
  @app.route("/translate", methods=["POST"])
331
  def translate_endpoint():
332
+ """
333
+ Translation API endpoint.
334
+
335
+ Request JSON:
336
+ - text: str - Text to translate
337
+ - direction: str - "en-km" or "km-en"
338
+
339
+ Response JSON:
340
+ - success: bool
341
+ - translation: str
342
+ - metrics: dict with confidence scores
343
+ """
344
  data = request.json
345
  text = data.get("text", "")
346
  direction = data.get("direction", "en-km")
 
352
 
353
  try:
354
  result, metrics = translate_long(text, src_lang, tgt_lang)
355
+ return jsonify({
356
+ "success": True,
357
+ "translation": result,
358
+ "metrics": metrics
359
+ })
360
  except Exception as e:
361
+ logger.error(f"Translation failed: {e}")
362
+ return jsonify({
363
+ "success": False,
364
+ "error": str(e)
365
+ })
366
+
367
 
368
  @app.route("/upload-pdf", methods=["POST"])
369
  def upload_pdf():
370
+ """
371
+ PDF upload endpoint.
372
+
373
+ Accepts multipart form with 'file' field.
374
+ Returns extracted text.
375
+ """
376
  if 'file' not in request.files:
377
  return jsonify({"success": False, "error": "No file uploaded"})
378
 
 
389
  else:
390
  return jsonify({"success": False, "error": "Could not extract text"})
391
 
392
+
393
+ @app.route("/health", methods=["GET"])
394
+ def health_check():
395
+ """Health check endpoint for monitoring."""
396
+ return jsonify({
397
+ "status": "healthy",
398
+ "model": MODEL_ID,
399
+ "comet_enabled": USE_COMET
400
+ })
401
+
402
+
403
+ # ============================================================================
404
+ # Main Entry Point
405
+ # ============================================================================
406
+
407
  if __name__ == "__main__":
408
+ port = int(os.environ.get("PORT", 7860))
409
+ app.run(host="0.0.0.0", port=port)