shayekh commited on
Commit
10735e1
·
verified ·
1 Parent(s): 26f196e

Added live text, show extraction, add website

Browse files
Files changed (1) hide show
  1. app.py +464 -44
app.py CHANGED
@@ -1,5 +1,9 @@
1
  # Copyright: Shayekh Bin Islam. KAIST, South Korea. 2026.
2
 
 
 
 
 
3
 
4
  try:
5
  import spaces
@@ -54,6 +58,53 @@ def extract_pdf_content(pdf_path, max_pages=2):
54
  images.append(img)
55
  return text, images
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  def get_base64_image(image):
59
  buffered = io.BytesIO()
@@ -62,13 +113,13 @@ def get_base64_image(image):
62
  return f"data:image/jpeg;base64,{img_str}"
63
 
64
  @spaces.GPU(duration=120)
65
- def extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_lang):
66
  """Use Transformers to extract vocabulary from text and images."""
67
  global model, processor
68
 
69
  os.makedirs("log", exist_ok=True)
70
 
71
- if pdf_text.strip() == "":
72
  pdf_text = '''"No Text available, see provided images only."'''
73
 
74
 
@@ -76,16 +127,20 @@ def extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_
76
  if translit_lang.upper() != "ENGLISH":
77
  non_english = f" CRITICAL: You MUST use the native alphabet/script of {translit_lang.upper()}, do NOT use English letters unless requested."
78
 
79
- prompt_text = f"""Extract 3 to 5 key Korean words or phrases from the following text and images.
 
 
 
80
  Return ONLY a valid JSON list of dictionaries, where each dictionary has four keys:
81
  - 'korean' (the Korean text)
82
  - 'transliteration' (the pronunciation transliterated into {translit_lang.upper()} script/characters, formatted as {translit_format}.{non_english})
83
  - 'translation' (the translation into {target_lang.upper()})
84
  - 'explanation' (a brief grammar or context note in {target_lang.upper()}).
85
  No markdown formatting, just raw JSON with ```json and ``` markers.
 
86
 
87
  Text:
88
- {pdf_text[:1500]}
89
  """
90
 
91
  # DEBUG: Log prompt text
@@ -122,13 +177,112 @@ Text:
122
  return_tensors="pt",
123
  padding=True
124
  ).to("cuda")
 
 
 
125
 
126
- generated_ids = model.generate(
 
127
  **inputs,
128
- # max_new_tokens=2048*4,
129
  max_new_tokens=2048*16,
130
- temperature=1.0,
131
- top_p=0.95,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  do_sample=True
133
  )
134
 
@@ -137,24 +291,22 @@ Text:
137
  ]
138
  output_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
139
 
140
- # DEBUG: Log raw output text
141
- with open("log/debug_vlm_output.txt", "w", encoding="utf-8") as f:
142
  f.write(output_text)
143
 
144
  except Exception as e:
145
- print(f"Error during Transformers inference: {e}")
146
  return []
147
 
148
  try:
149
  import re
150
- # Extract JSON from markdown code fences or raw output
151
- json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', output_text)
152
- if json_match:
153
- clean_text = json_match.group(1).strip()
154
  else:
155
- # Fallback: find first [ ... ] or { ... } block
156
- json_match = re.search(r'(\[[\s\S]*\]|\{[\s\S]*\})', output_text)
157
- clean_text = json_match.group(1).strip() if json_match else output_text.strip()
158
 
159
  data = json.loads(clean_text)
160
  if not isinstance(data, list):
@@ -172,8 +324,14 @@ def numpy_to_base64_audio(wav, sample_rate):
172
  audio_base64 = base64.b64encode(buffer.read()).decode('utf-8')
173
  return f"data:audio/wav;base64,{audio_base64}"
174
 
 
 
 
 
 
 
175
  @spaces.GPU(duration=120)
176
- def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=gr.Progress()):
177
  global tts, voice_style
178
 
179
  # Clean language choices from "Family - Language" to just "Language"
@@ -184,26 +342,59 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
184
 
185
  os.makedirs("log", exist_ok=True)
186
 
187
- if pdf_file is None:
188
- return "<p>Please upload a PDF.</p>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  try:
191
- progress(0, desc="Reading PDF...")
192
- pdf_text, images = extract_pdf_content(pdf_file.name)
193
- if not pdf_text.strip() and not images:
194
- return "<p>No content found in PDF.</p>"
 
 
 
 
 
 
195
  except Exception as e:
196
- return f"<p>Error reading PDF: {e}</p>"
 
197
 
198
  vocab_list = []
 
199
  for attempt in range(1, 4):
200
  progress(0.2, desc=f"Extracting vocabulary (Attempt {attempt}/3)...")
201
- vocab_list = extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_lang)
 
 
 
 
 
202
  if vocab_list:
203
  break
204
 
205
  if not vocab_list:
206
- return "<p>Failed to extract vocabulary after 3 attempts. The model might not have found Korean text or returned an invalid format.</p>"
 
207
 
208
  progress(0.6, desc="Generating TTS audio...")
209
  # Pre-generate TTS audio
@@ -214,7 +405,11 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
214
  korean += "."
215
 
216
  try:
217
- wav, dur = tts.synthesize(korean, voice_style=voice_style, lang="ko")
 
 
 
 
218
 
219
  # DEBUG: Save audio locally
220
  wav_1d = wav.squeeze()
@@ -250,8 +445,8 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
250
  }}
251
  .flashcard {{
252
  width: 100%;
253
- height: 350px;
254
- position: relative;
255
  transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1);
256
  transform-style: preserve-3d;
257
  cursor: pointer;
@@ -260,9 +455,8 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
260
  transform: rotateY(180deg);
261
  }}
262
  .card-face {{
263
- position: absolute;
264
  width: 100%;
265
- height: 100%;
266
  backface-visibility: hidden;
267
  display: flex;
268
  flex-direction: column;
@@ -370,6 +564,16 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
370
  font-size: 14px;
371
  font-weight: 600;
372
  }}
 
 
 
 
 
 
 
 
 
 
373
  </style>
374
  </head>
375
  <body>
@@ -377,12 +581,18 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
377
  <div class="flashcard-container">
378
  <div class="flashcard" id="card" onclick="flipCard()">
379
  <div class="card-face card-front">
380
- <div class="korean-text" id="front-text"><i class="fi fi-rr-spinner-third fa-spin"></i> Loading...</div>
 
 
 
381
  <button class="audio-btn" onclick="playAudio(event)" id="audio-btn" style="display:none;"><i class="fi fi-rr-play-circle"></i> Play Audio</button>
382
  <p style="margin-top:20px; color:#999; font-size:13px; display:flex; align-items:center; gap:5px;"><i class="fi fi-rr-rotate-right"></i> Click card to flip 🎯</p>
383
  </div>
384
  <div class="card-face card-back">
385
- <div class="english-text" id="back-en"></div>
 
 
 
386
  <div class="translit-text" id="back-translit"></div>
387
  <div class="explanation-text"><i class="fi fi-rr-lightbulb-on" style="color:#f1c40f;"></i> <span id="back-exp"></span></div>
388
  </div>
@@ -405,6 +615,8 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
405
  document.getElementById('front-text').innerHTML = "No vocabulary found 😥";
406
  document.getElementById('prev-btn').disabled = true;
407
  document.getElementById('next-btn').disabled = true;
 
 
408
  return;
409
  }}
410
  const card = cards[currentIndex];
@@ -413,6 +625,9 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
413
  document.getElementById('back-translit').innerText = card.transliteration ? `[${{card.transliteration}}]` : "";
414
  document.getElementById('back-exp').innerText = card.explanation || "";
415
 
 
 
 
416
  document.getElementById('prev-btn').disabled = currentIndex === 0;
417
  document.getElementById('next-btn').disabled = currentIndex === cards.length - 1;
418
  document.getElementById('progress-text').innerHTML = `📚 Card ${{currentIndex + 1}} of ${{cards.length}}`;
@@ -452,6 +667,20 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
452
  }}
453
  }}
454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  window.onload = function() {{
456
  updateCard();
457
  }};
@@ -464,7 +693,7 @@ def process_pdf(pdf_file, translit_lang, translit_format, target_lang, progress=
464
  safe_srcdoc = html.escape(iframe_html)
465
 
466
  # Return the iframe containing the whole SPA
467
- return f'<iframe srcdoc="{safe_srcdoc}" style="width: 100%; height: 500px; border: none; overflow: hidden;"></iframe>'
468
 
469
  LANGUAGE_DATA = """Indo-European English, French, Portuguese, German, Romanian, Swedish, Danish, Bulgarian, Russian, Czech, Greek, Ukrainian, Spanish, Dutch, Slovak, Croatian, Polish, Lithuanian, Norwegian Bokmål, Norwegian Nynorsk, Persian, Slovenian, Gujarati, Latvian, Italian, Occitan, Nepali, Marathi, Belarusian, Serbian, Luxembourgish, Venetian, Assamese, Welsh, Silesian, Asturian, Chhattisgarhi, Awadhi, Maithili, Bhojpuri, Sindhi, Irish, Faroese, Hindi, Punjabi, Bengali, Oriya, Tajik, Eastern Yiddish, Lombard, Ligurian, Sicilian, Friulian, Sardinian, Galician, Catalan, Icelandic, Tosk Albanian, Limburgish, Dari, Afrikaans, Macedonian, Sinhala, Urdu, Magahi, Bosnian, Armenian, Latgalian, Scottish Gaelic, Central Kurdish, Northern Kurdish, Southern Pashto, Sanskrit, Dhundari, Marwari, Ahirani, Bagheli, Bagri, Bundeli, Braj, Kumaoni, Kashmiri
470
  Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
@@ -499,13 +728,22 @@ def get_example_pdf():
499
  def create_demo():
500
  example_pdf = get_example_pdf()
501
 
502
- with gr.Blocks(title="LocalDuo") as demo:
503
- gr.Markdown("# 🇰🇷✨ LocalDuo - Learn Korean from PDFs")
504
- gr.Markdown("Upload a Korean book 📖 or document PDF 📄. The app uses **vLLM** 🧠 with Qwen3.5-2B to extract vocabulary from text and images, and Supertonic 🗣️ to generate pronunciation audio.")
 
 
 
 
 
 
 
 
505
 
506
  with gr.Row():
507
  with gr.Column(scale=1):
508
- pdf_input = gr.File(label="Upload Book PDF 📚", file_types=[".pdf"], value=example_pdf)
 
509
 
510
  gr.Markdown("### ⚙️ Customization Settings")
511
  translit_lang = gr.Dropdown(
@@ -519,16 +757,42 @@ def create_demo():
519
  choices=LANGUAGE_CHOICES,
520
  value="Indo-European - English"
521
  )
 
522
 
523
- submit_btn = gr.Button("✨ Generate Flashcards ✨", variant="primary")
 
 
524
 
525
  with gr.Column(scale=2):
526
  output_html = gr.HTML(label="Flashcards will appear here")
 
 
 
 
 
527
 
528
- submit_btn.click(
 
 
 
529
  fn=process_pdf,
530
- inputs=[pdf_input, translit_lang, translit_format, target_lang],
531
- outputs=output_html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  )
533
  return demo
534
 
@@ -549,6 +813,162 @@ if __name__ == "__main__":
549
  # processor.chat_template = f.read()
550
  # except Exception as e:
551
  # print("Could not load custom chat template:", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
  model = AutoModelForImageTextToText.from_pretrained(
554
  model_id,
 
1
  # Copyright: Shayekh Bin Islam. KAIST, South Korea. 2026.
2
 
3
+ MAX_TEXT_CHAR = 1500
4
+
5
+ # model_id = "Qwen/Qwen3.5-9B"
6
+ model_id = "Qwen/Qwen3.5-2B"
7
 
8
  try:
9
  import spaces
 
58
  images.append(img)
59
  return text, images
60
 
61
+ def extract_website_content(url, max_images=2):
62
+ """Extract text and images from a website URL."""
63
+ import requests
64
+ from bs4 import BeautifulSoup
65
+ import io
66
+
67
+ headers = {
68
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
69
+ }
70
+ response = requests.get(url, headers=headers, timeout=10)
71
+ response.raise_for_status()
72
+
73
+ soup = BeautifulSoup(response.content, 'html.parser')
74
+
75
+ for script in soup(["script", "style", "nav", "footer", "header", "noscript"]):
76
+ script.extract()
77
+
78
+ text = soup.get_text(separator='\n')
79
+ lines = (line.strip() for line in text.splitlines())
80
+ chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
81
+ text = '\n'.join(chunk for chunk in chunks if chunk)
82
+
83
+ images = []
84
+ img_tags = soup.find_all('img')
85
+ for img in img_tags:
86
+ if len(images) >= max_images:
87
+ break
88
+ src = img.get('src') or img.get('data-src')
89
+ if src:
90
+ if src.startswith('//'):
91
+ src = 'https:' + src
92
+ elif src.startswith('/'):
93
+ from urllib.parse import urljoin
94
+ src = urljoin(url, src)
95
+
96
+ try:
97
+ img_resp = requests.get(src, headers=headers, timeout=5)
98
+ if img_resp.status_code == 200:
99
+ pil_img = Image.open(io.BytesIO(img_resp.content))
100
+ if pil_img.mode != 'RGB':
101
+ pil_img = pil_img.convert('RGB')
102
+ if pil_img.width >= 100 and pil_img.height >= 100:
103
+ images.append(pil_img)
104
+ except Exception as e:
105
+ print(f"Failed to load image {src}: {e}")
106
+
107
+ return text, images
108
 
109
  def get_base64_image(image):
110
  buffered = io.BytesIO()
 
113
  return f"data:image/jpeg;base64,{img_str}"
114
 
115
  @spaces.GPU(duration=120)
116
+ def extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_lang, max_text_char=1500):
117
  """Use Transformers to extract vocabulary from text and images."""
118
  global model, processor
119
 
120
  os.makedirs("log", exist_ok=True)
121
 
122
+ if len(pdf_text.strip()) == 0:
123
  pdf_text = '''"No Text available, see provided images only."'''
124
 
125
 
 
127
  if translit_lang.upper() != "ENGLISH":
128
  non_english = f" CRITICAL: You MUST use the native alphabet/script of {translit_lang.upper()}, do NOT use English letters unless requested."
129
 
130
+ prompt_text = f"""Extract at least 10 key Korean words or phrases from the following text and images.
131
+ Focus on meaningful vocabulary that is highly helpful for a new language learner (e.g., common nouns, verbs, adjectives, or useful expressions).
132
+ CRITICAL: Do NOT extract website template words, navigation menus, boilerplate text, UI elements, or titles like 'Home page', 'News', 'Menu'.
133
+
134
  Return ONLY a valid JSON list of dictionaries, where each dictionary has four keys:
135
  - 'korean' (the Korean text)
136
  - 'transliteration' (the pronunciation transliterated into {translit_lang.upper()} script/characters, formatted as {translit_format}.{non_english})
137
  - 'translation' (the translation into {target_lang.upper()})
138
  - 'explanation' (a brief grammar or context note in {target_lang.upper()}).
139
  No markdown formatting, just raw JSON with ```json and ``` markers.
140
+ CRITICAL: Do NOT provide any conversational filler, thinking steps, or reasoning. Answer quick without very long thinking. Output the JSON array IMMEDIATELY.
141
 
142
  Text:
143
+ {pdf_text[:int(max_text_char)]}
144
  """
145
 
146
  # DEBUG: Log prompt text
 
177
  return_tensors="pt",
178
  padding=True
179
  ).to("cuda")
180
+
181
+ from transformers import TextIteratorStreamer
182
+ from threading import Thread
183
 
184
+ streamer = TextIteratorStreamer(processor.tokenizer, skip_prompt=True, skip_special_tokens=True)
185
+ generation_kwargs = dict(
186
  **inputs,
187
+ streamer=streamer,
188
  max_new_tokens=2048*16,
189
+ do_sample=True,
190
+ repetition_penalty=1.1,
191
+ )
192
+
193
+ if len(images) > 0:
194
+ generation_kwargs.update(dict(temperature=0.6, top_p=0.95, top_k=20, min_p=0.0))
195
+ else:
196
+ generation_kwargs.update(dict(temperature=1.0, top_p=0.95, top_k=20, min_p=0.0))
197
+
198
+ thread = Thread(target=model.generate, kwargs=generation_kwargs)
199
+ thread.start()
200
+
201
+ output_text = ""
202
+ for new_text in streamer:
203
+ output_text += new_text
204
+ yield output_text, None
205
+
206
+ # DEBUG: Log raw output text
207
+ with open("log/debug_vlm_output.txt", "w", encoding="utf-8") as f:
208
+ f.write(output_text)
209
+
210
+ except Exception as e:
211
+ print(f"Error during Transformers inference: {e}")
212
+ yield f"Error during Transformers inference: {e}", []
213
+ return
214
+
215
+ try:
216
+ import re
217
+ # Extract JSON from markdown code fences or raw output
218
+ json_matches = list(re.finditer(r'```(?:json)?\s*([\s\S]*?)```', output_text))
219
+ if json_matches:
220
+ clean_text = json_matches[-1].group(1).strip()
221
+ else:
222
+ # Fallback: find last [ ... ] or { ... } block
223
+ json_matches = list(re.finditer(r'(\[[\s\S]*\]|\{[\s\S]*\})', output_text))
224
+ clean_text = json_matches[-1].group(1).strip() if json_matches else output_text.strip()
225
+
226
+ data = json.loads(clean_text)
227
+ if not isinstance(data, list):
228
+ data = [data]
229
+ yield output_text, data
230
+ except Exception as e:
231
+ print(f"Error parsing JSON: {e}\nRaw output: {output_text}")
232
+ yield output_text, []
233
+
234
+ def translate_vocabulary(korean_words, translit_lang, translit_format, target_lang):
235
+ """Use Transformers text-only inference to translate/transliterate Korean words."""
236
+ global model, processor
237
+
238
+ non_english = ""
239
+ if translit_lang.upper() != "ENGLISH":
240
+ non_english = f" CRITICAL: You MUST use the native alphabet/script of {translit_lang.upper()}, do NOT use English letters unless requested."
241
+
242
+ words_str = ", ".join(korean_words)
243
+ prompt_text = f"""Translate and transliterate the following Korean words.
244
+ Return ONLY a valid JSON list of dictionaries, where each dictionary has four keys:
245
+ - 'korean' (the original Korean text)
246
+ - 'transliteration' (the pronunciation transliterated into {translit_lang.upper()} script/characters, formatted as {translit_format}.{non_english})
247
+ - 'translation' (the translation into {target_lang.upper()})
248
+ - 'explanation' (a brief grammar or context note in {target_lang.upper()}).
249
+ No markdown formatting, just raw JSON with ```json and ``` markers.
250
+ CRITICAL: Do NOT provide any conversational filler, thinking steps, or reasoning. Answer quick without very long thinking. Output the JSON array IMMEDIATELY.
251
+
252
+ Korean words:
253
+ {words_str}
254
+ """
255
+
256
+ # DEBUG: Log translation prompt text
257
+ with open("log/debug_translate_prompt.txt", "w", encoding="utf-8") as f:
258
+ f.write(prompt_text)
259
+
260
+ messages = [
261
+ {
262
+ "role": "user",
263
+ "content": [{"type": "text", "text": prompt_text}]
264
+ }
265
+ ]
266
+
267
+ try:
268
+ model.to("cuda")
269
+ text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
270
+ inputs = processor(
271
+ text=[text],
272
+ images=None,
273
+ return_tensors="pt",
274
+ padding=True
275
+ ).to("cuda")
276
+
277
+ generated_ids = model.generate(
278
+ **inputs,
279
+ # max_new_tokens=2048*16,
280
+ max_new_tokens=2048*2,
281
+ # temperature=1.0,
282
+ # top_p=0.95,
283
+ temperature=1.0, top_p=0.95, top_k=20, min_p=0.0,
284
+ # presence_penalty=1.5,
285
+ repetition_penalty=1.1,
286
  do_sample=True
287
  )
288
 
 
291
  ]
292
  output_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
293
 
294
+ # DEBUG: Log raw translation output text
295
+ with open("log/debug_translate_output.txt", "w", encoding="utf-8") as f:
296
  f.write(output_text)
297
 
298
  except Exception as e:
299
+ print(f"Error during Transformers text inference: {e}")
300
  return []
301
 
302
  try:
303
  import re
304
+ json_matches = list(re.finditer(r'```(?:json)?\s*([\s\S]*?)```', output_text))
305
+ if json_matches:
306
+ clean_text = json_matches[-1].group(1).strip()
 
307
  else:
308
+ json_matches = list(re.finditer(r'(\[[\s\S]*\]|\{[\s\S]*\})', output_text))
309
+ clean_text = json_matches[-1].group(1).strip() if json_matches else output_text.strip()
 
310
 
311
  data = json.loads(clean_text)
312
  if not isinstance(data, list):
 
324
  audio_base64 = base64.b64encode(buffer.read()).decode('utf-8')
325
  return f"data:audio/wav;base64,{audio_base64}"
326
 
327
+ import hashlib
328
+
329
+ def hash_file(filepath):
330
+ with open(filepath, 'rb') as f:
331
+ return hashlib.md5(f.read(1024*1024)).hexdigest()
332
+
333
  @spaces.GPU(duration=120)
334
+ def process_pdf(pdf_file, url_input, translit_lang, translit_format, target_lang, max_text_char, last_source_hash, last_korean_words, progress=gr.Progress()):
335
  global tts, voice_style
336
 
337
  # Clean language choices from "Family - Language" to just "Language"
 
342
 
343
  os.makedirs("log", exist_ok=True)
344
 
345
+ is_url = bool(url_input and url_input.strip())
346
+ if pdf_file is None and not is_url:
347
+ yield "<p>Please upload a PDF or enter a URL.</p>", None, None, ""
348
+ return
349
+
350
+ if is_url:
351
+ current_source_hash = hashlib.md5(url_input.strip().encode()).hexdigest()
352
+ else:
353
+ current_source_hash = hash_file(pdf_file.name)
354
+
355
+ vocab_list = []
356
+
357
+ # if last_source_hash == current_source_hash and last_korean_words:
358
+ # # Just run text-to-text LLM
359
+ # progress(0.2, desc="Translating previously extracted vocabulary...")
360
+ # korean_words = [item.get("korean") for item in last_korean_words if item.get("korean")]
361
+ # for attempt in range(1, 4):
362
+ # vocab_list = translate_vocabulary(korean_words, translit_lang, translit_format, target_lang)
363
+ # if vocab_list:
364
+ # break
365
+ # else:
366
 
367
  try:
368
+ if is_url:
369
+ progress(0, desc="Fetching Website...")
370
+ content_text, images = extract_website_content(url_input.strip())
371
+ else:
372
+ progress(0, desc="Reading PDF...")
373
+ content_text, images = extract_pdf_content(pdf_file.name)
374
+
375
+ if not content_text.strip() and not images:
376
+ yield "<p>No content found.</p>", current_source_hash, None, "", "", []
377
+ return
378
  except Exception as e:
379
+ yield f"<p>Error reading content: {e}</p>", None, None, "", "", []
380
+ return
381
 
382
  vocab_list = []
383
+ stream_text = ""
384
  for attempt in range(1, 4):
385
  progress(0.2, desc=f"Extracting vocabulary (Attempt {attempt}/3)...")
386
+ for stream_t, v_list in extract_vocabulary(content_text, images, translit_lang, translit_format, target_lang, max_text_char):
387
+ stream_text = stream_t
388
+ if v_list is not None:
389
+ vocab_list = v_list
390
+ yield "", current_source_hash, None, stream_text, content_text, images
391
+
392
  if vocab_list:
393
  break
394
 
395
  if not vocab_list:
396
+ yield "<p>Failed to extract or translate vocabulary after 3 attempts.</p>", current_source_hash, None, stream_text, content_text, images
397
+ return
398
 
399
  progress(0.6, desc="Generating TTS audio...")
400
  # Pre-generate TTS audio
 
405
  korean += "."
406
 
407
  try:
408
+ wav, dur = tts.synthesize(
409
+ korean, voice_style=voice_style, lang="ko",
410
+ total_steps=12,
411
+ speed=0.7,
412
+ )
413
 
414
  # DEBUG: Save audio locally
415
  wav_1d = wav.squeeze()
 
445
  }}
446
  .flashcard {{
447
  width: 100%;
448
+ min-height: 400px;
449
+ display: grid;
450
  transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1);
451
  transform-style: preserve-3d;
452
  cursor: pointer;
 
455
  transform: rotateY(180deg);
456
  }}
457
  .card-face {{
458
+ grid-area: 1 / 1;
459
  width: 100%;
 
460
  backface-visibility: hidden;
461
  display: flex;
462
  flex-direction: column;
 
564
  font-size: 14px;
565
  font-weight: 600;
566
  }}
567
+ .copy-icon {{
568
+ cursor: pointer;
569
+ color: #a0a0a0;
570
+ font-size: 24px;
571
+ transition: color 0.2s, transform 0.2s;
572
+ }}
573
+ .copy-icon:hover {{
574
+ color: #7c3aed;
575
+ transform: scale(1.1);
576
+ }}
577
  </style>
578
  </head>
579
  <body>
 
581
  <div class="flashcard-container">
582
  <div class="flashcard" id="card" onclick="flipCard()">
583
  <div class="card-face card-front">
584
+ <div style="display: flex; align-items: center; justify-content: center; gap: 15px; margin-bottom: 20px; width: 100%;">
585
+ <div class="korean-text" id="front-text" style="margin-bottom: 0;"><i class="fi fi-rr-spinner-third fa-spin"></i> Loading...</div>
586
+ <i class="fi fi-rr-copy copy-icon" id="copy-ko" onclick="copyText('front-text', event, this)" title="Copy text" style="display:none;"></i>
587
+ </div>
588
  <button class="audio-btn" onclick="playAudio(event)" id="audio-btn" style="display:none;"><i class="fi fi-rr-play-circle"></i> Play Audio</button>
589
  <p style="margin-top:20px; color:#999; font-size:13px; display:flex; align-items:center; gap:5px;"><i class="fi fi-rr-rotate-right"></i> Click card to flip 🎯</p>
590
  </div>
591
  <div class="card-face card-back">
592
+ <div style="display: flex; align-items: center; justify-content: center; gap: 15px; margin-bottom: 5px; width: 100%;">
593
+ <div class="english-text" id="back-en" style="margin-bottom: 0;"></div>
594
+ <i class="fi fi-rr-copy copy-icon" id="copy-en" onclick="copyText('back-en', event, this)" title="Copy text" style="display:none;"></i>
595
+ </div>
596
  <div class="translit-text" id="back-translit"></div>
597
  <div class="explanation-text"><i class="fi fi-rr-lightbulb-on" style="color:#f1c40f;"></i> <span id="back-exp"></span></div>
598
  </div>
 
615
  document.getElementById('front-text').innerHTML = "No vocabulary found 😥";
616
  document.getElementById('prev-btn').disabled = true;
617
  document.getElementById('next-btn').disabled = true;
618
+ if(document.getElementById('copy-ko')) document.getElementById('copy-ko').style.display = 'none';
619
+ if(document.getElementById('copy-en')) document.getElementById('copy-en').style.display = 'none';
620
  return;
621
  }}
622
  const card = cards[currentIndex];
 
625
  document.getElementById('back-translit').innerText = card.transliteration ? `[${{card.transliteration}}]` : "";
626
  document.getElementById('back-exp').innerText = card.explanation || "";
627
 
628
+ if(document.getElementById('copy-ko')) document.getElementById('copy-ko').style.display = 'block';
629
+ if(document.getElementById('copy-en')) document.getElementById('copy-en').style.display = 'block';
630
+
631
  document.getElementById('prev-btn').disabled = currentIndex === 0;
632
  document.getElementById('next-btn').disabled = currentIndex === cards.length - 1;
633
  document.getElementById('progress-text').innerHTML = `📚 Card ${{currentIndex + 1}} of ${{cards.length}}`;
 
667
  }}
668
  }}
669
 
670
+ function copyText(elementId, e, iconEl) {{
671
+ e.stopPropagation();
672
+ const textToCopy = document.getElementById(elementId).innerText;
673
+ navigator.clipboard.writeText(textToCopy).then(() => {{
674
+ const oldClass = iconEl.className;
675
+ iconEl.className = "fi fi-rr-check copy-icon";
676
+ iconEl.style.color = "#27ae60";
677
+ setTimeout(() => {{
678
+ iconEl.className = oldClass;
679
+ iconEl.style.color = "";
680
+ }}, 1500);
681
+ }}).catch(err => console.log('Copy failed', err));
682
+ }}
683
+
684
  window.onload = function() {{
685
  updateCard();
686
  }};
 
693
  safe_srcdoc = html.escape(iframe_html)
694
 
695
  # Return the iframe containing the whole SPA
696
+ yield f'<iframe srcdoc="{safe_srcdoc}" style="width: 100%; height: 650px; border: none; overflow-y: auto;"></iframe>', current_source_hash, vocab_list, stream_text, content_text, images
697
 
698
  LANGUAGE_DATA = """Indo-European English, French, Portuguese, German, Romanian, Swedish, Danish, Bulgarian, Russian, Czech, Greek, Ukrainian, Spanish, Dutch, Slovak, Croatian, Polish, Lithuanian, Norwegian Bokmål, Norwegian Nynorsk, Persian, Slovenian, Gujarati, Latvian, Italian, Occitan, Nepali, Marathi, Belarusian, Serbian, Luxembourgish, Venetian, Assamese, Welsh, Silesian, Asturian, Chhattisgarhi, Awadhi, Maithili, Bhojpuri, Sindhi, Irish, Faroese, Hindi, Punjabi, Bengali, Oriya, Tajik, Eastern Yiddish, Lombard, Ligurian, Sicilian, Friulian, Sardinian, Galician, Catalan, Icelandic, Tosk Albanian, Limburgish, Dari, Afrikaans, Macedonian, Sinhala, Urdu, Magahi, Bosnian, Armenian, Latgalian, Scottish Gaelic, Central Kurdish, Northern Kurdish, Southern Pashto, Sanskrit, Dhundari, Marwari, Ahirani, Bagheli, Bagri, Bundeli, Braj, Kumaoni, Kashmiri
699
  Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
 
728
  def create_demo():
729
  example_pdf = get_example_pdf()
730
 
731
+ css = """
732
+ #stream_box .progress-text,
733
+ #stream_box .progress-level,
734
+ #stream_box .progress,
735
+ #stream_box .progress-container {
736
+ display: none !important;
737
+ }
738
+ """
739
+ with gr.Blocks(title="LocalDuo", css=css) as demo:
740
+ gr.Markdown("# 🇰🇷✨ LocalDuo - Learn Korean from PDFs & Websites")
741
+ gr.Markdown("Enter a website URL 🌐 or upload a Korean book PDF 📄. The app uses a **Vision-Language Model (VLM)** 🧠 to extract vocabulary from text and images, and a **Text-to-Speech (TTS)** engine 🗣️ to generate pronunciation audio.")
742
 
743
  with gr.Row():
744
  with gr.Column(scale=1):
745
+ url_input = gr.Textbox(label="Enter a Website URL 🌐", placeholder="e.g. https://www.bbc.com/korean", value="https://www.bbc.com/korean")
746
+ pdf_input = gr.File(label="Or Upload Book PDF 📚", file_types=[".pdf"], value=example_pdf)
747
 
748
  gr.Markdown("### ⚙️ Customization Settings")
749
  translit_lang = gr.Dropdown(
 
757
  choices=LANGUAGE_CHOICES,
758
  value="Indo-European - English"
759
  )
760
+ max_text_char_input = gr.Slider(minimum=1000, maximum=30000, step=1000, value=1500, label="Max Input Text Length (Characters)")
761
 
762
+ with gr.Row():
763
+ submit_btn = gr.Button("✨ Generate Flashcards ✨", variant="primary")
764
+ stop_btn = gr.Button("🛑 Stop Generation", variant="stop")
765
 
766
  with gr.Column(scale=2):
767
  output_html = gr.HTML(label="Flashcards will appear here")
768
+ stream_box = gr.Textbox(label="Live Model Generation 🧠", lines=10, max_lines=20, interactive=False, autoscroll=True, elem_id="stream_box")
769
+
770
+ with gr.Accordion("📄 Extracted Source Content", open=True):
771
+ extracted_text_box = gr.Textbox(label="Extracted Text", lines=10, max_lines=15, interactive=False)
772
+ extracted_images_gallery = gr.Gallery(label="Extracted Images", columns=4, height="auto", object_fit="contain")
773
 
774
+ last_source_state = gr.State(None)
775
+ last_korean_words_state = gr.State(None)
776
+
777
+ generate_event = submit_btn.click(
778
  fn=process_pdf,
779
+ inputs=[pdf_input, url_input, translit_lang, translit_format, target_lang, max_text_char_input, last_source_state, last_korean_words_state],
780
+ outputs=[output_html, last_source_state, last_korean_words_state, stream_box, extracted_text_box, extracted_images_gallery]
781
+ )
782
+
783
+ stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[generate_event])
784
+
785
+ # Force autoscroll using Custom JS
786
+ stream_box.change(
787
+ fn=None,
788
+ js="""
789
+ function() {
790
+ const ta = document.querySelector('#stream_box textarea');
791
+ if (ta) {
792
+ ta.scrollTop = ta.scrollHeight;
793
+ }
794
+ }
795
+ """
796
  )
797
  return demo
798
 
 
813
  # processor.chat_template = f.read()
814
  # except Exception as e:
815
  # print("Could not load custom chat template:", e)
816
+ processor.chat_template = """
817
+ {%- set image_count = namespace(value=0) %}
818
+ {%- set video_count = namespace(value=0) %}
819
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
820
+ {%- if content is string %}
821
+ {{- content }}
822
+ {%- elif content is iterable and content is not mapping %}
823
+ {%- for item in content %}
824
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
825
+ {%- if is_system_content %}
826
+ {{- raise_exception('System message cannot contain images.') }}
827
+ {%- endif %}
828
+ {%- if do_vision_count %}
829
+ {%- set image_count.value = image_count.value + 1 %}
830
+ {%- endif %}
831
+ {%- if add_vision_id %}
832
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
833
+ {%- endif %}
834
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
835
+ {%- elif 'video' in item or item.type == 'video' %}
836
+ {%- if is_system_content %}
837
+ {{- raise_exception('System message cannot contain videos.') }}
838
+ {%- endif %}
839
+ {%- if do_vision_count %}
840
+ {%- set video_count.value = video_count.value + 1 %}
841
+ {%- endif %}
842
+ {%- if add_vision_id %}
843
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
844
+ {%- endif %}
845
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
846
+ {%- elif 'text' in item %}
847
+ {{- item.text }}
848
+ {%- else %}
849
+ {{- raise_exception('Unexpected item type in content.') }}
850
+ {%- endif %}
851
+ {%- endfor %}
852
+ {%- elif content is none or content is undefined %}
853
+ {{- '' }}
854
+ {%- else %}
855
+ {{- raise_exception('Unexpected content type.') }}
856
+ {%- endif %}
857
+ {%- endmacro %}
858
+ {%- if not messages %}
859
+ {{- raise_exception('No messages provided.') }}
860
+ {%- endif %}
861
+ {%- if tools and tools is iterable and tools is not mapping %}
862
+ {{- '<|im_start|>system\n' }}
863
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
864
+ {%- for tool in tools %}
865
+ {{- "\n" }}
866
+ {{- tool | tojson }}
867
+ {%- endfor %}
868
+ {{- "\n</tools>" }}
869
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
870
+ {%- if messages[0].role == 'system' %}
871
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
872
+ {%- if content %}
873
+ {{- '\n\n' + content }}
874
+ {%- endif %}
875
+ {%- endif %}
876
+ {{- '<|im_end|>\n' }}
877
+ {%- else %}
878
+ {%- if messages[0].role == 'system' %}
879
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
880
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
881
+ {%- endif %}
882
+ {%- endif %}
883
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
884
+ {%- for message in messages[::-1] %}
885
+ {%- set index = (messages|length - 1) - loop.index0 %}
886
+ {%- if ns.multi_step_tool and message.role == "user" %}
887
+ {%- set content = render_content(message.content, false)|trim %}
888
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
889
+ {%- set ns.multi_step_tool = false %}
890
+ {%- set ns.last_query_index = index %}
891
+ {%- endif %}
892
+ {%- endif %}
893
+ {%- endfor %}
894
+ {%- if ns.multi_step_tool %}
895
+ {{- raise_exception('No user query found in messages.') }}
896
+ {%- endif %}
897
+ {%- for message in messages %}
898
+ {%- set content = render_content(message.content, true)|trim %}
899
+ {%- if message.role == "system" %}
900
+ {%- if not loop.first %}
901
+ {{- raise_exception('System message must be at the beginning.') }}
902
+ {%- endif %}
903
+ {%- elif message.role == "user" %}
904
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
905
+ {%- elif message.role == "assistant" %}
906
+ {%- set reasoning_content = '' %}
907
+ {%- if message.reasoning_content is string %}
908
+ {%- set reasoning_content = message.reasoning_content %}
909
+ {%- else %}
910
+ {%- if '</think>' in content %}
911
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
912
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
913
+ {%- endif %}
914
+ {%- endif %}
915
+ {%- set reasoning_content = reasoning_content|trim %}
916
+ {%- if loop.index0 > ns.last_query_index %}
917
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
918
+ {%- else %}
919
+ {{- '<|im_start|>' + message.role + '\n' + content }}
920
+ {%- endif %}
921
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
922
+ {%- for tool_call in message.tool_calls %}
923
+ {%- if tool_call.function is defined %}
924
+ {%- set tool_call = tool_call.function %}
925
+ {%- endif %}
926
+ {%- if loop.first %}
927
+ {%- if content|trim %}
928
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
929
+ {%- else %}
930
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
931
+ {%- endif %}
932
+ {%- else %}
933
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
934
+ {%- endif %}
935
+ {%- if tool_call.arguments is defined %}
936
+ {%- for args_name, args_value in tool_call.arguments|items %}
937
+ {{- '<parameter=' + args_name + '>\n' }}
938
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
939
+ {{- args_value }}
940
+ {{- '\n</parameter>\n' }}
941
+ {%- endfor %}
942
+ {%- endif %}
943
+ {{- '</function>\n</tool_call>' }}
944
+ {%- endfor %}
945
+ {%- endif %}
946
+ {{- '<|im_end|>\n' }}
947
+ {%- elif message.role == "tool" %}
948
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
949
+ {{- '<|im_start|>user' }}
950
+ {%- endif %}
951
+ {{- '\n<tool_response>\n' }}
952
+ {{- content }}
953
+ {{- '\n</tool_response>' }}
954
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
955
+ {{- '<|im_end|>\n' }}
956
+ {%- elif loop.last %}
957
+ {{- '<|im_end|>\n' }}
958
+ {%- endif %}
959
+ {%- else %}
960
+ {{- raise_exception('Unexpected message role.') }}
961
+ {%- endif %}
962
+ {%- endfor %}
963
+ {%- if add_generation_prompt %}
964
+ {{- '<|im_start|>assistant\n' }}
965
+ {%- if enable_thinking is defined and enable_thinking is false %}
966
+ {{- '<think>\n\n</think>\n\n' }}
967
+ {%- else %}
968
+ {{- '<think>\n' }}
969
+ {%- endif %}
970
+ {%- endif %}
971
+ """.strip()
972
 
973
  model = AutoModelForImageTextToText.from_pretrained(
974
  model_id,